AI Agent for Interior Design: Automate Space Planning, Material Selection & Project Management

Vibrant abstract 3D artwork with geometric patterns and colorful hues.

Photo by Google DeepMind on Pexels

March 28, 2026 15 min read Interior Design

The global interior design market generates over $150 billion annually, yet most design firms still rely on manual space planning in CAD, spreadsheets for procurement tracking, and endless email threads for client approvals. A single residential project can involve 200+ material specifications, 15-30 vendor interactions, and dozens of revision cycles. These bottlenecks eat into margins that already average just 15-25% for most firms.

AI agents built for interior design go far beyond simple image generation. They reason about spatial constraints, building codes, ergonomic standards, material performance data, and client preferences simultaneously to produce layouts, specifications, and project plans that would take a human designer hours to assemble. From optimizing furniture placement for traffic flow to tracking 50 open purchase orders across vendors, these agents deliver measurable time savings from day one.

This guide covers six core areas where AI agents transform interior design operations, with production-ready Python code for each. Whether you run a boutique studio or a 50-person firm, these patterns scale to your practice.

Table of Contents

1. Space Planning & Layout Optimization

Traditional space planning starts with a designer manually placing furniture blocks in AutoCAD or SketchUp, iterating through layouts until one feels right. This process is heavily dependent on the designer's experience and typically explores only 3-5 layout variations. An AI agent can evaluate thousands of possible configurations in seconds, scoring each against objective criteria: traffic flow clearances (minimum 36 inches for primary paths, 24 inches for secondary), furniture-to-wall proportions, focal point alignment, and accessibility compliance under ADA or local building codes.

Furniture Placement and Traffic Flow

The core challenge in automated layout generation is balancing competing constraints. A living room needs a conversation area where seating faces the focal point (fireplace, TV, or window view), but it also needs clear paths to every doorway, adequate lighting reach from fixtures, and proportional negative space so the room does not feel cramped. The agent models the room as a 2D grid, places furniture items as bounding rectangles with orientation constraints, and uses scoring functions to evaluate each configuration against ergonomic and aesthetic rules.

Beyond basic placement, the agent handles natural light simulation by calculating sun angles at different times of day based on window orientation and latitude, recommending desk placement for home offices to avoid screen glare, and identifying areas that need supplemental lighting. For acoustic planning, it scores material combinations based on NRC (Noise Reduction Coefficient) ratings and identifies parallel hard surfaces that create flutter echo, suggesting diffusion solutions like bookcases or textured wall panels.

import math
from dataclasses import dataclass, field
from typing import List, Tuple, Optional, Dict
import random

@dataclass
class Room:
    width_ft: float
    length_ft: float
    ceiling_height_ft: float
    doors: List[Tuple[float, float, float]]    # (x, y, width)
    windows: List[Tuple[float, float, float]]   # (x, y, width)
    orientation_deg: float                       # 0=north, 90=east
    focal_point: Optional[Tuple[float, float]] = None

@dataclass
class FurnitureItem:
    name: str
    width_ft: float
    depth_ft: float
    height_ft: float
    can_rotate: bool = True
    wall_required: bool = False      # must be against a wall
    min_clearance_ft: float = 2.0    # clearance around item
    category: str = "seating"        # seating, table, storage, lighting

@dataclass
class PlacedItem:
    item: FurnitureItem
    x: float
    y: float
    rotation: float  # degrees

class SpacePlanningAgent:
    """AI agent for room layout generation and spatial optimization."""

    PRIMARY_PATH_WIDTH = 3.0     # feet - main traffic paths
    SECONDARY_PATH_WIDTH = 2.0   # feet - between furniture
    ADA_WHEELCHAIR_CLEAR = 5.0   # feet turning radius
    CONVERSATION_DISTANCE = 8.0  # feet max for seating groups
    MIN_WALL_ART_HEIGHT = 4.5    # feet center height

    def __init__(self, room: Room, furniture: List[FurnitureItem]):
        self.room = room
        self.furniture = furniture
        self.grid_resolution = 0.5  # feet

    def generate_layouts(self, count: int = 500) -> List[dict]:
        """Generate and score multiple layout candidates."""
        layouts = []
        for _ in range(count):
            placement = self._random_placement()
            if placement:
                score = self._score_layout(placement)
                layouts.append({"placement": placement, "score": score})

        layouts.sort(key=lambda l: l["score"]["total"], reverse=True)
        return layouts[:5]  # return top 5

    def _random_placement(self) -> Optional[List[PlacedItem]]:
        placed = []
        for item in self.furniture:
            for attempt in range(50):
                rotation = random.choice([0, 90]) if item.can_rotate else 0
                w = item.width_ft if rotation == 0 else item.depth_ft
                d = item.depth_ft if rotation == 0 else item.width_ft

                if item.wall_required:
                    x, y = self._wall_position(w, d)
                else:
                    x = random.uniform(1, self.room.width_ft - w - 1)
                    y = random.uniform(1, self.room.length_ft - d - 1)

                candidate = PlacedItem(item, x, y, rotation)
                if not self._collides(candidate, placed):
                    placed.append(candidate)
                    break
            else:
                return None  # could not place this item
        return placed

    def _score_layout(self, placement: List[PlacedItem]) -> dict:
        traffic = self._score_traffic_flow(placement)
        focal = self._score_focal_alignment(placement)
        proportion = self._score_proportions(placement)
        lighting = self._score_natural_light(placement)
        acoustic = self._score_acoustics(placement)
        ergonomic = self._score_ergonomics(placement)

        total = (
            traffic * 0.25
            + focal * 0.20
            + proportion * 0.15
            + lighting * 0.15
            + acoustic * 0.10
            + ergonomic * 0.15
        )
        return {
            "total": round(total, 2),
            "traffic_flow": round(traffic, 2),
            "focal_alignment": round(focal, 2),
            "proportions": round(proportion, 2),
            "natural_light": round(lighting, 2),
            "acoustics": round(acoustic, 2),
            "ergonomics": round(ergonomic, 2)
        }

    def _score_traffic_flow(self, placement: List[PlacedItem]) -> float:
        """Score path clearance from each door to every other door."""
        score = 100.0
        for i, door_a in enumerate(self.room.doors):
            for door_b in self.room.doors[i+1:]:
                clearance = self._min_path_clearance(
                    door_a, door_b, placement
                )
                if clearance < self.PRIMARY_PATH_WIDTH:
                    penalty = (self.PRIMARY_PATH_WIDTH - clearance) * 20
                    score -= penalty
        return max(0, score)

    def _score_focal_alignment(self, placement: List[PlacedItem]) -> float:
        if not self.room.focal_point:
            return 80.0
        score = 100.0
        fx, fy = self.room.focal_point
        seating = [p for p in placement if p.item.category == "seating"]
        for seat in seating:
            cx = seat.x + seat.item.width_ft / 2
            cy = seat.y + seat.item.depth_ft / 2
            distance = math.sqrt((cx - fx)**2 + (cy - fy)**2)
            if distance > self.CONVERSATION_DISTANCE:
                score -= 15
        return max(0, score)

    def _score_natural_light(self, placement: List[PlacedItem]) -> float:
        """Penalize tall furniture blocking windows."""
        score = 100.0
        for window in self.room.windows:
            wx, wy, ww = window
            for p in placement:
                if (p.item.height_ft > 3.0 and
                    abs(p.x - wx) < ww + 1.0 and
                    abs(p.y - wy) < 2.0):
                    score -= 20
        return max(0, score)

    def _score_proportions(self, placement: List[PlacedItem]) -> float:
        room_area = self.room.width_ft * self.room.length_ft
        furniture_area = sum(
            p.item.width_ft * p.item.depth_ft for p in placement
        )
        fill_ratio = furniture_area / room_area
        ideal = 0.35  # 30-40% fill is ideal
        deviation = abs(fill_ratio - ideal)
        return max(0, 100 - deviation * 300)

    def _score_acoustics(self, placement: List[PlacedItem]) -> float:
        """Basic acoustic scoring: penalize bare parallel walls."""
        storage = [p for p in placement if p.item.category == "storage"]
        wall_coverage = len(storage) * 4.0 / (
            2 * (self.room.width_ft + self.room.length_ft)
        )
        return min(100, 60 + wall_coverage * 200)

    def _score_ergonomics(self, placement: List[PlacedItem]) -> float:
        score = 100.0
        for p in placement:
            if p.item.category == "seating":
                nearby_tables = [
                    t for t in placement if t.item.category == "table"
                    and self._distance(p, t) < 3.0
                ]
                if not nearby_tables:
                    score -= 10  # seating without a surface nearby
        return max(0, score)

    def _collides(self, candidate: PlacedItem,
                  placed: List[PlacedItem]) -> bool:
        cw = candidate.item.width_ft if candidate.rotation == 0 else candidate.item.depth_ft
        cd = candidate.item.depth_ft if candidate.rotation == 0 else candidate.item.width_ft
        margin = candidate.item.min_clearance_ft

        for p in placed:
            pw = p.item.width_ft if p.rotation == 0 else p.item.depth_ft
            pd = p.item.depth_ft if p.rotation == 0 else p.item.width_ft
            if (candidate.x < p.x + pw + margin and
                candidate.x + cw + margin > p.x and
                candidate.y < p.y + pd + margin and
                candidate.y + cd + margin > p.y):
                return True
        return False

    def _wall_position(self, w, d) -> Tuple[float, float]:
        wall = random.choice(["north", "south", "east", "west"])
        if wall == "north":
            return random.uniform(0.5, self.room.width_ft - w - 0.5), 0.0
        elif wall == "south":
            return random.uniform(0.5, self.room.width_ft - w - 0.5), self.room.length_ft - d
        elif wall == "west":
            return 0.0, random.uniform(0.5, self.room.length_ft - d - 0.5)
        return self.room.width_ft - w, random.uniform(0.5, self.room.length_ft - d - 0.5)

    def _min_path_clearance(self, door_a, door_b, placement) -> float:
        ax, ay, _ = door_a
        bx, by, _ = door_b
        min_clear = float("inf")
        steps = 10
        for i in range(steps + 1):
            t = i / steps
            px = ax + t * (bx - ax)
            py = ay + t * (by - ay)
            for p in placement:
                dist = self._point_rect_dist(px, py, p)
                min_clear = min(min_clear, dist)
        return min_clear

    def _point_rect_dist(self, px, py, placed: PlacedItem) -> float:
        w = placed.item.width_ft if placed.rotation == 0 else placed.item.depth_ft
        d = placed.item.depth_ft if placed.rotation == 0 else placed.item.width_ft
        dx = max(placed.x - px, 0, px - (placed.x + w))
        dy = max(placed.y - py, 0, py - (placed.y + d))
        return math.sqrt(dx**2 + dy**2)

    def _distance(self, a: PlacedItem, b: PlacedItem) -> float:
        ax = a.x + a.item.width_ft / 2
        ay = a.y + a.item.depth_ft / 2
        bx = b.x + b.item.width_ft / 2
        by = b.y + b.item.depth_ft / 2
        return math.sqrt((ax - bx)**2 + (ay - by)**2)
Key insight: Automated layout generation explores 100x more configurations than a human designer can in the same time. The scoring weights are tunable per project type -- a commercial office prioritizes traffic flow and ergonomics at 0.30 each, while a luxury residential living room weights focal alignment and proportions higher. Save scoring profiles per project type and refine them from client feedback.

2. Material & Product Selection

Material specification is one of the most time-consuming phases in interior design. A single commercial project can require 80-150 unique material specifications, each needing to meet performance requirements (fire rating, slip resistance, abrasion cycles), aesthetic criteria (color, texture, pattern scale), budget constraints, and lead time compatibility with the project schedule. Designers typically spend 30-40% of their project hours on material research, sampling, and vendor coordination.

Specification Matching and Vendor Comparison

The AI agent maintains a structured database of materials with quantified properties: Taber abrasion cycles, ASTM E84 flame spread ratings, Delta E color values, VOC content in g/L, and pricing tiers. When a designer specifies "durable, warm-toned, commercial-grade flooring under $8/sqft," the agent translates that into quantified filters (abrasion > 10,000 cycles, color temperature 2700-3500K, Class A fire rating, price < $8.00) and returns ranked matches from its vendor database. It also handles lead time alignment, filtering out any product that cannot arrive before the installation date.

For sustainability-conscious projects, the agent scores materials on embodied carbon (kgCO2e per unit), recycled content percentage, VOC emissions against CDPH or GREENGUARD standards, and end-of-life recyclability. This turns LEED or WELL certification requirements from a documentation burden into an automated filter that runs at the point of specification, not as a painful audit after the fact.

from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from datetime import datetime, timedelta

@dataclass
class MaterialSpec:
    id: str
    name: str
    category: str                    # "flooring", "wall", "fabric", "countertop"
    manufacturer: str
    price_per_unit: float
    unit: str                        # "sqft", "lnft", "yard", "each"
    lead_time_days: int
    min_order_qty: float
    fire_rating: str                 # "Class A", "Class B", "Class C"
    abrasion_cycles: int             # Taber test
    voc_g_per_liter: float
    recycled_content_pct: float
    embodied_carbon_kg: float        # per unit
    color_temperature_k: int         # warm/cool tone
    texture: str                     # "smooth", "textured", "rough"
    pattern_scale: str               # "solid", "small", "medium", "large"
    water_resistant: bool
    samples_available: bool

@dataclass
class ProjectRequirements:
    category: str
    min_abrasion: int = 0
    fire_rating_required: str = "Class B"
    max_price_per_unit: float = float("inf")
    max_voc: float = 50.0
    min_recycled_pct: float = 0.0
    color_temp_range: Tuple[int, int] = (2000, 6000)
    needed_by: Optional[datetime] = None
    quantity_needed: float = 0
    sustainability_priority: float = 0.5   # 0-1

class MaterialSelectionAgent:
    """AI agent for specification matching, vendor comparison, and sustainability scoring."""

    FIRE_RATING_ORDER = {"Class A": 3, "Class B": 2, "Class C": 1}

    def __init__(self, catalog: List[MaterialSpec]):
        self.catalog = catalog

    def find_materials(self, req: ProjectRequirements,
                       limit: int = 10) -> List[dict]:
        """Match materials against project requirements and rank."""
        candidates = []
        order_date = datetime.now()

        for mat in self.catalog:
            if mat.category != req.category:
                continue
            if mat.abrasion_cycles < req.min_abrasion:
                continue
            if self.FIRE_RATING_ORDER.get(mat.fire_rating, 0) < \
               self.FIRE_RATING_ORDER.get(req.fire_rating_required, 0):
                continue
            if mat.price_per_unit > req.max_price_per_unit:
                continue
            if mat.voc_g_per_liter > req.max_voc:
                continue
            if mat.recycled_content_pct < req.min_recycled_pct:
                continue
            if not (req.color_temp_range[0] <= mat.color_temperature_k
                    <= req.color_temp_range[1]):
                continue
            if req.needed_by:
                arrival = order_date + timedelta(days=mat.lead_time_days)
                if arrival > req.needed_by:
                    continue
            if req.quantity_needed > 0 and req.quantity_needed < mat.min_order_qty:
                continue

            score = self._score_material(mat, req)
            candidates.append({
                "material": mat,
                "score": round(score, 2),
                "total_cost": round(mat.price_per_unit * max(
                    req.quantity_needed, mat.min_order_qty
                ), 2),
                "sustainability_score": round(
                    self._sustainability_score(mat), 2
                ),
                "arrival_date": (
                    order_date + timedelta(days=mat.lead_time_days)
                ).strftime("%Y-%m-%d")
            })

        candidates.sort(key=lambda c: c["score"], reverse=True)
        return candidates[:limit]

    def compare_vendors(self, material_ids: List[str],
                        quantity: float) -> List[dict]:
        """Side-by-side vendor comparison for equivalent materials."""
        comparisons = []
        for mat in self.catalog:
            if mat.id in material_ids:
                order_qty = max(quantity, mat.min_order_qty)
                waste_factor = 1.10 if mat.category == "flooring" else 1.05
                total = mat.price_per_unit * order_qty * waste_factor

                comparisons.append({
                    "id": mat.id,
                    "name": mat.name,
                    "manufacturer": mat.manufacturer,
                    "unit_price": mat.price_per_unit,
                    "order_qty": order_qty,
                    "waste_factor": waste_factor,
                    "total_cost": round(total, 2),
                    "lead_time_days": mat.lead_time_days,
                    "moq_met": quantity >= mat.min_order_qty,
                    "sustainability": round(
                        self._sustainability_score(mat), 2
                    )
                })
        comparisons.sort(key=lambda c: c["total_cost"])
        return comparisons

    def _score_material(self, mat: MaterialSpec,
                        req: ProjectRequirements) -> float:
        price_score = max(0, 100 - (
            mat.price_per_unit / req.max_price_per_unit
        ) * 100) if req.max_price_per_unit < float("inf") else 50

        sustain_score = self._sustainability_score(mat)
        lead_score = max(0, 100 - mat.lead_time_days * 1.5)
        durability_score = min(100, mat.abrasion_cycles / 200)

        w_s = req.sustainability_priority
        w_p = (1 - w_s) * 0.4
        w_d = (1 - w_s) * 0.35
        w_l = (1 - w_s) * 0.25

        return (
            price_score * w_p
            + sustain_score * w_s
            + durability_score * w_d
            + lead_score * w_l
        )

    def _sustainability_score(self, mat: MaterialSpec) -> float:
        voc_score = max(0, 100 - mat.voc_g_per_liter * 2)
        recycled_score = mat.recycled_content_pct
        carbon_score = max(0, 100 - mat.embodied_carbon_kg * 10)
        return (voc_score * 0.35 + recycled_score * 0.35
                + carbon_score * 0.30)
Key insight: The biggest time sink in material selection is not finding the first option -- it is comparing the top 3-5 candidates across price, lead time, sustainability, and durability. Automating this comparison with quantified scoring eliminates the "analysis paralysis" that adds weeks to the specification phase. Firms that automate material matching report reducing specification time by 60-70%.

3. 3D Visualization & Client Presentation

Client presentations consume a disproportionate amount of design time. Creating a single photorealistic rendering can take 4-8 hours of modeling, material mapping, lighting setup, and render processing. Mood boards, while faster, still require designers to manually curate images, extract color palettes, and ensure visual coherence. An AI agent automates the mechanical parts of visualization while keeping the designer's creative intent at the center of every output.

Mood Board Generation and Style Transfer

The agent begins by analyzing reference images a client provides or a designer selects. It extracts dominant colors (converting to LAB color space for perceptual accuracy), identifies material textures (wood grain patterns, marble veining, fabric weaves), classifies the overall style (mid-century modern, Japandi, maximalist, industrial), and generates a structured style profile. From this profile, it creates mood board compositions by pulling matching images from curated databases, arranging them in visually balanced grids with extracted color swatches and material callouts.

For photorealistic rendering automation, the agent maps specified materials onto 3D model surfaces by matching material IDs to PBR texture sets (albedo, normal, roughness, metallic maps), positions lighting to match the room's actual window layout and time-of-day conditions, and queues renders at appropriate resolution for the presentation stage -- lower resolution for initial concepts, full 4K for final client approval. Style transfer capabilities let designers provide a reference image ("I want the warmth of this Bali resort lobby") and the agent adjusts material choices, color temperature, and lighting to approximate that aesthetic within the project's actual floor plan.

from dataclasses import dataclass, field
from typing import List, Dict, Tuple, Optional
import math
import colorsys

@dataclass
class ColorPalette:
    primary: Tuple[int, int, int]       # RGB
    secondary: Tuple[int, int, int]
    accent: Tuple[int, int, int]
    neutral_light: Tuple[int, int, int]
    neutral_dark: Tuple[int, int, int]

@dataclass
class StyleProfile:
    name: str                             # "mid-century modern", "japandi"
    color_palette: ColorPalette
    dominant_materials: List[str]         # ["walnut", "brass", "linen"]
    pattern_density: float               # 0-1, minimal to maximalist
    contrast_level: float                # 0-1
    warmth: float                        # 0=cool, 1=warm
    era_reference: str                   # "1950s", "contemporary"

@dataclass
class RenderJob:
    scene_file: str
    camera_angle: str
    resolution: Tuple[int, int]
    materials_map: Dict[str, str]        # surface_id -> material_path
    lighting_preset: str
    time_of_day: str
    output_path: str

class VisualizationAgent:
    """AI agent for mood boards, rendering automation, and style transfer."""

    STYLE_SIGNATURES = {
        "mid-century-modern": {
            "materials": ["walnut", "teak", "brass", "wool", "leather"],
            "warmth": 0.75, "contrast": 0.6, "pattern_density": 0.3
        },
        "japandi": {
            "materials": ["oak", "linen", "ceramic", "paper", "bamboo"],
            "warmth": 0.55, "contrast": 0.3, "pattern_density": 0.15
        },
        "industrial": {
            "materials": ["steel", "concrete", "reclaimed-wood", "glass", "iron"],
            "warmth": 0.3, "contrast": 0.75, "pattern_density": 0.2
        },
        "maximalist": {
            "materials": ["velvet", "marble", "gold", "silk", "lacquer"],
            "warmth": 0.7, "contrast": 0.8, "pattern_density": 0.85
        }
    }

    def extract_style_profile(self, reference_colors: List[Tuple[int, int, int]],
                               reference_materials: List[str]) -> StyleProfile:
        """Analyze reference inputs and classify design style."""
        avg_warmth = self._calculate_warmth(reference_colors)
        contrast = self._calculate_contrast(reference_colors)
        best_style = self._match_style(reference_materials, avg_warmth)
        palette = self._generate_palette(reference_colors)

        return StyleProfile(
            name=best_style,
            color_palette=palette,
            dominant_materials=reference_materials[:5],
            pattern_density=self.STYLE_SIGNATURES.get(
                best_style, {}
            ).get("pattern_density", 0.3),
            contrast_level=contrast,
            warmth=avg_warmth,
            era_reference="contemporary"
        )

    def generate_mood_board(self, profile: StyleProfile,
                            image_db: List[dict]) -> dict:
        """Create a mood board layout from style profile."""
        matched = []
        for img in image_db:
            similarity = self._style_similarity(profile, img)
            if similarity > 0.6:
                matched.append({"image": img, "score": similarity})

        matched.sort(key=lambda m: m["score"], reverse=True)
        selected = matched[:9]  # 3x3 grid

        return {
            "style": profile.name,
            "palette": {
                "primary": profile.color_palette.primary,
                "secondary": profile.color_palette.secondary,
                "accent": profile.color_palette.accent
            },
            "images": [s["image"]["path"] for s in selected],
            "materials": profile.dominant_materials,
            "layout": "3x3_grid",
            "annotations": self._generate_annotations(profile)
        }

    def prepare_render_batch(self, scene_file: str,
                              materials_map: Dict[str, str],
                              camera_angles: List[str],
                              profile: StyleProfile) -> List[RenderJob]:
        """Queue render jobs with correct materials and lighting."""
        lighting = self._lighting_for_warmth(profile.warmth)
        jobs = []

        for angle in camera_angles:
            for stage, res in [("concept", (1920, 1080)),
                               ("final", (3840, 2160))]:
                jobs.append(RenderJob(
                    scene_file=scene_file,
                    camera_angle=angle,
                    resolution=res,
                    materials_map=materials_map,
                    lighting_preset=lighting,
                    time_of_day="10:00" if profile.warmth > 0.5 else "14:00",
                    output_path=f"renders/{angle}_{stage}.png"
                ))
        return jobs

    def style_transfer_recommendations(self, source_profile: StyleProfile,
                                        target_ref: dict) -> List[dict]:
        """Suggest material and color changes to match a reference."""
        recommendations = []
        target_warmth = target_ref.get("warmth", 0.5)
        warmth_delta = target_warmth - source_profile.warmth

        if abs(warmth_delta) > 0.15:
            direction = "warmer" if warmth_delta > 0 else "cooler"
            recommendations.append({
                "category": "color_temperature",
                "action": f"Shift palette {direction}",
                "current": round(source_profile.warmth, 2),
                "target": round(target_warmth, 2),
                "suggestions": self._warmth_adjustments(warmth_delta)
            })

        target_materials = target_ref.get("materials", [])
        missing = [m for m in target_materials
                   if m not in source_profile.dominant_materials]
        if missing:
            recommendations.append({
                "category": "materials",
                "action": "Introduce reference materials",
                "add": missing[:3],
                "replace_candidates": source_profile.dominant_materials[-2:]
            })

        return recommendations

    def _calculate_warmth(self, colors: List[Tuple[int, int, int]]) -> float:
        warmth_scores = []
        for r, g, b in colors:
            h, s, v = colorsys.rgb_to_hsv(r/255, g/255, b/255)
            hue_deg = h * 360
            if hue_deg < 60 or hue_deg > 300:
                warmth_scores.append(0.8)
            elif 60 <= hue_deg <= 180:
                warmth_scores.append(0.3)
            else:
                warmth_scores.append(0.5)
        return sum(warmth_scores) / len(warmth_scores) if warmth_scores else 0.5

    def _calculate_contrast(self, colors: List[Tuple[int, int, int]]) -> float:
        if len(colors) < 2:
            return 0.5
        luminances = [0.299*r + 0.587*g + 0.114*b for r, g, b in colors]
        return (max(luminances) - min(luminances)) / 255

    def _match_style(self, materials: List[str], warmth: float) -> str:
        best, best_score = "contemporary", 0
        for style, sig in self.STYLE_SIGNATURES.items():
            overlap = len(set(materials) & set(sig["materials"]))
            warmth_match = 1 - abs(warmth - sig["warmth"])
            score = overlap * 2 + warmth_match
            if score > best_score:
                best, best_score = style, score
        return best

    def _generate_palette(self, colors: List[Tuple[int, int, int]]) -> ColorPalette:
        sorted_c = sorted(colors, key=lambda c: sum(c), reverse=True)
        while len(sorted_c) < 5:
            sorted_c.append((128, 128, 128))
        return ColorPalette(*sorted_c[:5])

    def _style_similarity(self, profile: StyleProfile, img: dict) -> float:
        mat_overlap = len(
            set(profile.dominant_materials) & set(img.get("materials", []))
        )
        warmth_match = 1 - abs(profile.warmth - img.get("warmth", 0.5))
        return (mat_overlap * 0.3 + warmth_match * 0.7)

    def _lighting_for_warmth(self, warmth: float) -> str:
        if warmth > 0.7:
            return "golden_hour"
        elif warmth > 0.4:
            return "overcast_soft"
        return "cool_daylight"

    def _warmth_adjustments(self, delta: float) -> List[str]:
        if delta > 0:
            return ["Add warm wood tones", "Use amber accent lighting",
                    "Introduce terracotta or rust textiles"]
        return ["Switch to cool-toned metals", "Use blue-gray textiles",
                "Increase white and glass surfaces"]

    def _generate_annotations(self, profile: StyleProfile) -> List[str]:
        return [
            f"Style: {profile.name.replace('-', ' ').title()}",
            f"Warmth: {'warm' if profile.warmth > 0.5 else 'cool'} palette",
            f"Key materials: {', '.join(profile.dominant_materials[:3])}"
        ]
Key insight: The real value of automated visualization is not replacing the designer's eye -- it is eliminating the 4-6 hours of mechanical setup (material mapping, lighting configuration, render queuing) so designers can focus on creative decisions. Firms that automate render pipelines report producing 3x more presentation options per project, which directly increases client approval rates on first presentation from 40% to over 75%.

4. Project Management & Coordination

Interior design projects fail not because of bad design but because of bad coordination. A typical residential renovation involves 8-15 trade contractors, 30-50 purchase orders, and a timeline where a 2-week delay on countertop fabrication cascades into rescheduling plumbers, electricians, and tile installers. Most firms track this in spreadsheets or basic project management tools that cannot model dependencies or automatically adjust timelines when changes occur.

Procurement Tracking and Contractor Coordination

The AI agent maintains a real-time procurement database linked to the project timeline. When a vendor confirms a shipping delay, the agent immediately identifies every downstream task affected, calculates the new critical path, and generates a revised schedule that minimizes overall project delay. It also handles trade sequencing logic -- ensuring rough electrical is complete before drywall, that HVAC ductwork is installed before soffits are framed, and that finish trades do not overlap in the same room on the same day.

Budget tracking goes beyond simple line-item accounting. The agent monitors allowance burn rates (clients often have a "tile allowance" or "lighting allowance"), flags when selections exceed allowances before the order is placed, calculates the cumulative impact of change orders on the project margin, and forecasts final project cost based on current spending velocity versus remaining specifications. This prevents the all-too-common scenario where a project is 20% over budget before anyone notices.

from dataclasses import dataclass, field
from typing import List, Dict, Optional, Set
from datetime import datetime, timedelta
from enum import Enum

class TaskStatus(Enum):
    PENDING = "pending"
    IN_PROGRESS = "in_progress"
    DELAYED = "delayed"
    COMPLETED = "completed"
    BLOCKED = "blocked"

@dataclass
class PurchaseOrder:
    po_id: str
    vendor: str
    item_description: str
    quantity: float
    unit_cost: float
    order_date: datetime
    expected_delivery: datetime
    actual_delivery: Optional[datetime] = None
    status: str = "ordered"          # ordered, shipped, delivered, backordered
    allowance_category: str = ""     # "tile", "lighting", "plumbing"
    tracking_number: Optional[str] = None

@dataclass
class ProjectTask:
    task_id: str
    name: str
    trade: str                        # "electrical", "plumbing", "tile"
    duration_days: int
    dependencies: List[str] = field(default_factory=list)
    room: str = ""
    start_date: Optional[datetime] = None
    end_date: Optional[datetime] = None
    status: TaskStatus = TaskStatus.PENDING
    required_materials: List[str] = field(default_factory=list)

@dataclass
class ChangeOrder:
    co_id: str
    description: str
    cost_impact: float
    time_impact_days: int
    affected_tasks: List[str]
    approved: bool = False
    date: datetime = field(default_factory=datetime.now)

class ProjectManagementAgent:
    """AI agent for procurement, scheduling, and budget management."""

    def __init__(self, tasks: List[ProjectTask],
                 purchase_orders: List[PurchaseOrder],
                 budget: Dict[str, float]):
        self.tasks = {t.task_id: t for t in tasks}
        self.pos = {po.po_id: po for po in purchase_orders}
        self.budget = budget          # {"tile": 15000, "lighting": 8000, ...}
        self.change_orders = []

    def update_delivery(self, po_id: str,
                        new_delivery: datetime) -> dict:
        """Update delivery date and cascade schedule impact."""
        po = self.pos[po_id]
        old_date = po.expected_delivery
        po.expected_delivery = new_delivery
        delay_days = (new_delivery - old_date).days

        if delay_days <= 0:
            return {"impact": "none", "message": "Delivery improved"}

        affected_tasks = self._find_dependent_tasks(po_id)
        cascaded = self._cascade_delay(affected_tasks, delay_days)
        new_critical_path = self._calculate_critical_path()

        return {
            "po_id": po_id,
            "vendor": po.vendor,
            "delay_days": delay_days,
            "directly_affected_tasks": [t.name for t in affected_tasks],
            "total_cascaded_tasks": len(cascaded),
            "new_project_end": new_critical_path["end_date"],
            "critical_path": [
                t.name for t in new_critical_path["path"]
            ],
            "mitigation_options": self._suggest_mitigations(
                affected_tasks, delay_days
            )
        }

    def track_budget(self) -> dict:
        """Real-time budget status with allowance tracking."""
        spent_by_category = {}
        for po in self.pos.values():
            cat = po.allowance_category
            if cat not in spent_by_category:
                spent_by_category[cat] = 0
            spent_by_category[cat] += po.quantity * po.unit_cost

        co_impact = sum(co.cost_impact for co in self.change_orders
                        if co.approved)

        report = {"categories": {}, "total_budget": sum(self.budget.values())}
        total_spent = co_impact

        for category, allowance in self.budget.items():
            spent = spent_by_category.get(category, 0)
            total_spent += spent
            remaining = allowance - spent
            report["categories"][category] = {
                "allowance": allowance,
                "spent": round(spent, 2),
                "remaining": round(remaining, 2),
                "pct_used": round((spent / allowance) * 100, 1)
                    if allowance > 0 else 0,
                "status": "over" if remaining < 0
                          else "warning" if remaining < allowance * 0.15
                          else "on_track"
            }

        report["total_spent"] = round(total_spent, 2)
        report["change_order_impact"] = round(co_impact, 2)
        report["projected_final"] = round(
            total_spent * self._completion_factor(), 2
        )
        report["margin_impact"] = self._margin_status(total_spent)
        return report

    def optimize_schedule(self) -> dict:
        """Identify parallel tasks and compress timeline."""
        critical = self._calculate_critical_path()
        parallel_opportunities = []

        for tid, task in self.tasks.items():
            if task in critical["path"]:
                continue
            float_days = self._calculate_float(task)
            if float_days > 2:
                parallel_opportunities.append({
                    "task": task.name,
                    "float_days": float_days,
                    "can_parallel_with": [
                        t.name for t in critical["path"]
                        if self._can_overlap(task, t)
                    ]
                })

        return {
            "critical_path_days": critical["duration"],
            "critical_tasks": [t.name for t in critical["path"]],
            "parallel_opportunities": parallel_opportunities,
            "potential_savings_days": sum(
                min(p["float_days"], 3) for p in parallel_opportunities
            ) // 2
        }

    def _find_dependent_tasks(self, po_id: str) -> List[ProjectTask]:
        po = self.pos[po_id]
        return [
            t for t in self.tasks.values()
            if po_id in t.required_materials
        ]

    def _cascade_delay(self, tasks: List[ProjectTask],
                       delay_days: int) -> List[ProjectTask]:
        cascaded = set()
        queue = [t.task_id for t in tasks]
        while queue:
            tid = queue.pop(0)
            if tid in cascaded:
                continue
            cascaded.add(tid)
            task = self.tasks[tid]
            if task.start_date:
                task.start_date += timedelta(days=delay_days)
            if task.end_date:
                task.end_date += timedelta(days=delay_days)
            for other in self.tasks.values():
                if tid in other.dependencies:
                    queue.append(other.task_id)
        return [self.tasks[tid] for tid in cascaded]

    def _calculate_critical_path(self) -> dict:
        earliest = {}
        for tid in self._topological_sort():
            task = self.tasks[tid]
            dep_ends = [
                earliest[d]["end"] for d in task.dependencies
                if d in earliest
            ]
            start = max(dep_ends) if dep_ends else 0
            earliest[tid] = {
                "start": start,
                "end": start + task.duration_days
            }

        end_tid = max(earliest, key=lambda t: earliest[t]["end"])
        path = self._trace_path(end_tid, earliest)
        return {
            "duration": earliest[end_tid]["end"],
            "end_date": datetime.now() + timedelta(
                days=earliest[end_tid]["end"]
            ),
            "path": [self.tasks[t] for t in path]
        }

    def _topological_sort(self) -> List[str]:
        visited, order = set(), []
        def visit(tid):
            if tid in visited:
                return
            visited.add(tid)
            for dep in self.tasks[tid].dependencies:
                if dep in self.tasks:
                    visit(dep)
            order.append(tid)
        for tid in self.tasks:
            visit(tid)
        return order

    def _trace_path(self, end_tid: str, earliest: dict) -> List[str]:
        path = [end_tid]
        current = end_tid
        while self.tasks[current].dependencies:
            prev = max(
                self.tasks[current].dependencies,
                key=lambda d: earliest.get(d, {}).get("end", 0)
            )
            path.insert(0, prev)
            current = prev
        return path

    def _calculate_float(self, task: ProjectTask) -> int:
        return 5  # simplified: real implementation uses late-start minus early-start

    def _can_overlap(self, a: ProjectTask, b: ProjectTask) -> bool:
        return a.room != b.room and a.trade != b.trade

    def _completion_factor(self) -> float:
        done = sum(1 for t in self.tasks.values()
                   if t.status == TaskStatus.COMPLETED)
        total = len(self.tasks)
        return total / max(done, 1)

    def _margin_status(self, total_spent: float) -> str:
        total_budget = sum(self.budget.values())
        pct = (total_spent / total_budget) * 100 if total_budget else 0
        if pct > 95:
            return "critical - margin at risk"
        elif pct > 80:
            return "warning - monitor closely"
        return "healthy"

    def _suggest_mitigations(self, tasks: List[ProjectTask],
                              delay: int) -> List[str]:
        suggestions = []
        if delay <= 3:
            suggestions.append("Absorb with schedule float if available")
        if delay <= 7:
            suggestions.append("Request expedited shipping from vendor")
        suggestions.append("Resequence non-critical trades to fill gap")
        if delay > 5:
            suggestions.append("Consider alternate vendor with shorter lead time")
        if delay > 10:
            suggestions.append("Schedule client meeting to discuss timeline impact")
        return suggestions
Key insight: The average interior design project experiences 3-5 material delivery delays. Without automated cascade analysis, each delay triggers hours of manual schedule re-planning and phone calls. The agent's ability to instantly show "this 2-week tile delay pushes your move-in date by 8 days unless we resequence electrical and painting" transforms reactive firefighting into proactive decision-making.

5. Client Management & Business Development

Winning and retaining clients is where many talented designers struggle. The business development side of interior design -- lead qualification, proposal customization, preference tracking, and referral nurturing -- often falls to the principal designer who is already overloaded with active projects. An AI agent that handles the systematic parts of client management frees designers to focus on the relationship-driven aspects where human intuition matters most.

Preference Learning and Portfolio Matching

Every client interaction generates data about their preferences: the images they react positively to in mood board presentations, the materials they gravitate toward in showroom visits, their budget sensitivity (do they flinch at $12/sqft tile or $45/sqft?), and their decision-making pattern (decisive or consensus-driven). The agent builds a structured preference profile from these signals, which improves specification accuracy on subsequent rooms and future projects. For portfolio curation, when preparing a proposal for a new lead, the agent selects past projects that match the prospect's style preferences, budget range, and project scope, creating a targeted portfolio that resonates rather than a generic "best of" deck.

Lead scoring goes beyond simple project size. The agent evaluates timeline urgency (clients with a move-in deadline convert faster), budget clarity (clients who state a specific number are more serious than those who say "flexible"), scope definition (clients who know exactly which rooms they want designed are further along the buying process), and referral source quality (past client referrals close at 3x the rate of website inquiries). This scoring lets firms prioritize follow-ups and allocate design consultation time to the highest-probability leads.

from dataclasses import dataclass, field
from typing import List, Dict, Optional, Tuple
from datetime import datetime, timedelta

@dataclass
class ClientPreference:
    style_scores: Dict[str, float] = field(default_factory=dict)
    material_likes: List[str] = field(default_factory=list)
    material_dislikes: List[str] = field(default_factory=list)
    color_preferences: List[str] = field(default_factory=list)
    budget_sensitivity: float = 0.5     # 0=price insensitive, 1=very sensitive
    decision_speed: float = 0.5         # 0=slow/consensus, 1=fast/decisive
    sustainability_priority: float = 0.3
    interaction_count: int = 0

@dataclass
class Lead:
    lead_id: str
    name: str
    source: str                          # "referral", "website", "social", "event"
    project_type: str                    # "residential_full", "single_room", "commercial"
    stated_budget: Optional[float] = None
    timeline_months: Optional[int] = None
    rooms_defined: bool = False
    referral_from: Optional[str] = None
    first_contact: datetime = field(default_factory=datetime.now)
    last_contact: Optional[datetime] = None
    notes: List[str] = field(default_factory=list)

@dataclass
class PastProject:
    project_id: str
    client_name: str
    style: str
    budget_total: float
    project_type: str
    rooms: List[str]
    photos: List[str]
    completion_date: datetime
    client_satisfaction: float           # 1-5
    referrals_generated: int

class ClientManagementAgent:
    """AI agent for preference learning, lead scoring, and portfolio curation."""

    SOURCE_WEIGHTS = {
        "referral": 3.0, "repeat_client": 3.5,
        "website": 1.0, "social": 0.8, "event": 1.5
    }

    def __init__(self, portfolio: List[PastProject]):
        self.portfolio = portfolio
        self.client_profiles = {}
        self.leads = {}

    def update_preferences(self, client_id: str,
                           interaction: dict) -> ClientPreference:
        """Learn from client interaction to refine preference profile."""
        if client_id not in self.client_profiles:
            self.client_profiles[client_id] = ClientPreference()

        profile = self.client_profiles[client_id]
        profile.interaction_count += 1

        if "liked_images" in interaction:
            for img in interaction["liked_images"]:
                style = img.get("style", "modern")
                profile.style_scores[style] = profile.style_scores.get(
                    style, 0
                ) + 1.0
                profile.material_likes.extend(img.get("materials", []))

        if "rejected_images" in interaction:
            for img in interaction["rejected_images"]:
                style = img.get("style", "")
                profile.style_scores[style] = profile.style_scores.get(
                    style, 0
                ) - 0.5
                profile.material_dislikes.extend(img.get("materials", []))

        if "budget_reaction" in interaction:
            reaction = interaction["budget_reaction"]
            if reaction == "flinched":
                profile.budget_sensitivity = min(1.0,
                    profile.budget_sensitivity + 0.15)
            elif reaction == "comfortable":
                profile.budget_sensitivity = max(0.0,
                    profile.budget_sensitivity - 0.10)

        if "decision" in interaction:
            if interaction["decision"] == "immediate":
                profile.decision_speed = min(1.0,
                    profile.decision_speed + 0.2)
            elif interaction["decision"] == "needs_time":
                profile.decision_speed = max(0.0,
                    profile.decision_speed - 0.15)

        return profile

    def score_lead(self, lead: Lead) -> dict:
        """Score lead quality for prioritization."""
        score = 0
        factors = {}

        # Source quality
        source_w = self.SOURCE_WEIGHTS.get(lead.source, 1.0)
        source_score = source_w * 15
        score += source_score
        factors["source"] = round(source_score, 1)

        # Budget clarity
        if lead.stated_budget and lead.stated_budget > 0:
            budget_score = 20
            if lead.stated_budget > 50000:
                budget_score += 10
        else:
            budget_score = 5
        score += budget_score
        factors["budget_clarity"] = budget_score

        # Timeline urgency
        if lead.timeline_months:
            if lead.timeline_months <= 3:
                timeline_score = 25
            elif lead.timeline_months <= 6:
                timeline_score = 15
            else:
                timeline_score = 8
        else:
            timeline_score = 5
        score += timeline_score
        factors["timeline"] = timeline_score

        # Scope definition
        scope_score = 15 if lead.rooms_defined else 5
        score += scope_score
        factors["scope"] = scope_score

        # Referral bonus
        if lead.referral_from:
            referral_score = 15
        else:
            referral_score = 0
        score += referral_score
        factors["referral"] = referral_score

        # Engagement recency
        if lead.last_contact:
            days_since = (datetime.now() - lead.last_contact).days
            recency_score = max(0, 10 - days_since)
        else:
            recency_score = 3
        score += recency_score
        factors["recency"] = recency_score

        return {
            "lead_id": lead.lead_id,
            "name": lead.name,
            "total_score": round(score, 1),
            "max_possible": 100,
            "grade": "A" if score >= 75 else "B" if score >= 50
                     else "C" if score >= 30 else "D",
            "factors": factors,
            "recommended_action": self._lead_action(score, lead)
        }

    def curate_portfolio(self, lead: Lead,
                         max_projects: int = 5) -> List[dict]:
        """Select portfolio projects that match a prospect's profile."""
        scored = []
        for project in self.portfolio:
            relevance = 0

            # Project type match
            if project.project_type == lead.project_type:
                relevance += 30

            # Budget proximity (if known)
            if lead.stated_budget and lead.stated_budget > 0:
                budget_ratio = min(project.budget_total, lead.stated_budget) / \
                               max(project.budget_total, lead.stated_budget)
                relevance += budget_ratio * 25

            # Recency bonus: newer projects score higher
            months_ago = (datetime.now() - project.completion_date).days / 30
            relevance += max(0, 15 - months_ago)

            # Quality filter: only show high-satisfaction projects
            if project.client_satisfaction >= 4.5:
                relevance += 10

            # Photo availability
            relevance += min(10, len(project.photos) * 2)

            scored.append({
                "project": project,
                "relevance_score": round(relevance, 1)
            })

        scored.sort(key=lambda s: s["relevance_score"], reverse=True)
        return [
            {
                "project_id": s["project"].project_id,
                "client_name": s["project"].client_name,
                "style": s["project"].style,
                "budget": s["project"].budget_total,
                "photos": s["project"].photos[:3],
                "relevance": s["relevance_score"]
            }
            for s in scored[:max_projects]
        ]

    def track_referrals(self) -> dict:
        """Analyze referral network and identify nurturing opportunities."""
        referral_sources = {}
        for project in self.portfolio:
            if project.referrals_generated > 0:
                referral_sources[project.client_name] = {
                    "referrals": project.referrals_generated,
                    "project_value": project.budget_total,
                    "satisfaction": project.client_satisfaction
                }

        top_referrers = sorted(
            referral_sources.items(),
            key=lambda x: x[1]["referrals"], reverse=True
        )

        nurture_candidates = [
            p for p in self.portfolio
            if p.client_satisfaction >= 4.5
            and p.referrals_generated == 0
            and (datetime.now() - p.completion_date).days < 365
        ]

        return {
            "total_referral_revenue": sum(
                s["project_value"] * s["referrals"]
                for s in referral_sources.values()
            ),
            "top_referrers": [
                {"name": name, **data}
                for name, data in top_referrers[:5]
            ],
            "nurture_candidates": [
                {"name": p.client_name, "project_id": p.project_id,
                 "completed_days_ago": (datetime.now() - p.completion_date).days}
                for p in nurture_candidates
            ]
        }

    def _lead_action(self, score: float, lead: Lead) -> str:
        if score >= 75:
            return "Schedule design consultation within 48 hours"
        elif score >= 50:
            return "Send portfolio deck and follow up in 3 days"
        elif score >= 30:
            return "Add to email nurture sequence"
        return "Log and monitor - low priority"
Key insight: Referral clients have a 60-70% close rate compared to 15-20% for cold website leads, yet most firms do not systematically nurture past clients for referrals. The agent's referral tracking identifies high-satisfaction clients who have not yet referred anyone and flags them for a personal check-in at the 3-month, 6-month, and 12-month marks post-project completion.

6. ROI Analysis for a Design Firm (15 Designers, 200 Projects/Year)

Quantifying the return on AI agent investment for an interior design firm requires modeling efficiency gains across the entire project lifecycle. A mid-size firm with 15 designers handling 200 projects per year (a mix of residential renovations, new builds, and commercial fit-outs) spends roughly 60% of billable hours on tasks that AI agents can accelerate: space planning iterations, material research, procurement coordination, and client communication. The remaining 40% -- creative direction, client relationships, site visits -- stays firmly human.

Design Efficiency and Procurement Savings

Space planning automation reduces layout iteration time from an average of 6 hours per room to 1.5 hours (the agent generates 500 options in minutes, the designer curates and refines the top 3). Across 200 projects averaging 4 rooms each, that saves 3,600 designer hours annually. At a blended billing rate of $125/hour, those hours can be redirected to new revenue-generating projects or used to reduce project timelines, improving client satisfaction. Material selection automation cuts specification time by 60%, saving another 2,400 hours across the firm. On the procurement side, automated vendor comparison consistently finds pricing 8-12% lower than manual sourcing because the agent evaluates more vendors and catches volume discount opportunities that individual designers miss.

Project margin improvement comes from two sources: better budget tracking that catches overruns early (saving an average of $2,800 per project in avoided cost surprises) and timeline compression that reduces overhead allocation per project. Client acquisition improvements stem from faster proposal turnaround (lead-to-proposal time drops from 5 days to 1 day), more targeted portfolios, and systematic referral nurturing that increases the referral rate from 15% to 30% of completed projects.

from dataclasses import dataclass
from typing import Dict

class DesignFirmROIModel:
    """ROI model for AI agent deployment in a mid-size interior design firm."""

    def __init__(self, designers: int = 15, projects_per_year: int = 200):
        self.designers = designers
        self.projects = projects_per_year
        self.avg_rooms_per_project = 4
        self.billing_rate = 125           # USD/hour
        self.avg_project_value = 35000    # USD
        self.avg_material_spend = 18000   # USD per project

    def design_efficiency_savings(self) -> dict:
        """Calculate time savings from space planning and material automation."""
        # Space planning: 6 hrs -> 1.5 hrs per room
        rooms_total = self.projects * self.avg_rooms_per_project
        hours_saved_planning = rooms_total * (6.0 - 1.5)
        revenue_from_planning = hours_saved_planning * self.billing_rate

        # Material selection: 8 hrs -> 3 hrs per project
        hours_saved_materials = self.projects * (8.0 - 3.0)
        revenue_from_materials = hours_saved_materials * self.billing_rate

        # Visualization: 5 hrs -> 2 hrs per project
        hours_saved_viz = self.projects * (5.0 - 2.0)
        revenue_from_viz = hours_saved_viz * self.billing_rate

        total_hours = (hours_saved_planning + hours_saved_materials
                       + hours_saved_viz)
        total_value = (revenue_from_planning + revenue_from_materials
                       + revenue_from_viz)

        return {
            "hours_saved_space_planning": hours_saved_planning,
            "hours_saved_materials": hours_saved_materials,
            "hours_saved_visualization": hours_saved_viz,
            "total_hours_saved": total_hours,
            "equivalent_revenue": round(total_value, 0),
            "additional_projects_capacity": round(total_hours / 120, 0)
        }

    def procurement_savings(self) -> dict:
        """Savings from automated vendor comparison and MOQ optimization."""
        total_material_spend = self.projects * self.avg_material_spend

        # Agent finds 8-12% savings through broader vendor comparison
        vendor_savings_pct = 0.10
        vendor_savings = total_material_spend * vendor_savings_pct

        # MOQ optimization: grouping orders across projects saves 3-5%
        moq_savings_pct = 0.04
        moq_savings = total_material_spend * moq_savings_pct

        # Reduced error orders (wrong spec, wrong quantity): ~2% of spend
        error_reduction = total_material_spend * 0.02

        return {
            "total_material_spend": total_material_spend,
            "vendor_comparison_savings": round(vendor_savings, 0),
            "moq_optimization_savings": round(moq_savings, 0),
            "error_reduction_savings": round(error_reduction, 0),
            "total_procurement_savings": round(
                vendor_savings + moq_savings + error_reduction, 0
            )
        }

    def project_margin_improvement(self) -> dict:
        """Budget tracking and timeline compression impact on margins."""
        # Budget overrun prevention: avg $2,800 saved per project
        budget_savings = self.projects * 2800

        # Timeline compression: 15% faster projects reduce overhead
        overhead_per_project = self.avg_project_value * 0.20
        timeline_savings = self.projects * overhead_per_project * 0.15

        # Change order management: better tracking saves 1.5% of project value
        co_savings = self.projects * self.avg_project_value * 0.015

        return {
            "budget_overrun_prevention": round(budget_savings, 0),
            "timeline_compression_savings": round(timeline_savings, 0),
            "change_order_savings": round(co_savings, 0),
            "total_margin_improvement": round(
                budget_savings + timeline_savings + co_savings, 0
            )
        }

    def client_acquisition_gains(self) -> dict:
        """Revenue from faster proposals, better portfolios, more referrals."""
        # Faster proposal turnaround: 20% improvement in close rate
        current_close_rate = 0.25
        improved_close_rate = 0.30
        annual_leads = self.projects / current_close_rate
        additional_projects = annual_leads * (
            improved_close_rate - current_close_rate
        )
        proposal_revenue = additional_projects * self.avg_project_value

        # Referral improvement: 15% -> 30% referral rate
        current_referrals = self.projects * 0.15
        improved_referrals = self.projects * 0.30
        additional_referral_projects = (
            (improved_referrals - current_referrals) * 0.65
        )  # 65% close rate on referrals
        referral_revenue = additional_referral_projects * self.avg_project_value

        return {
            "additional_projects_from_proposals": round(additional_projects, 0),
            "proposal_improvement_revenue": round(proposal_revenue, 0),
            "additional_referral_projects": round(
                additional_referral_projects, 0
            ),
            "referral_revenue": round(referral_revenue, 0),
            "total_acquisition_gain": round(
                proposal_revenue + referral_revenue, 0
            )
        }

    def implementation_costs(self) -> dict:
        """Total cost of AI agent deployment."""
        return {
            "software_licenses": 36000,      # $3K/month for AI platform
            "api_costs": 18000,              # LLM API, rendering, etc.
            "integration_setup": 25000,      # one-time: connect to tools
            "training_staff": 12000,         # 2 days per designer
            "catalog_digitization": 15000,   # one-time: build material DB
            "annual_maintenance": 8000,
            "year_1_total": 114000,
            "annual_recurring": 62000
        }

    def full_roi_analysis(self) -> dict:
        """Complete ROI calculation."""
        efficiency = self.design_efficiency_savings()
        procurement = self.procurement_savings()
        margins = self.project_margin_improvement()
        acquisition = self.client_acquisition_gains()
        costs = self.implementation_costs()

        # Conservative estimate (60% of projected)
        conservative = round((
            efficiency["equivalent_revenue"] * 0.6
            + procurement["total_procurement_savings"] * 0.6
            + margins["total_margin_improvement"] * 0.6
            + acquisition["total_acquisition_gain"] * 0.6
        ), 0)

        # Optimistic estimate (100% of projected)
        optimistic = round((
            efficiency["equivalent_revenue"]
            + procurement["total_procurement_savings"]
            + margins["total_margin_improvement"]
            + acquisition["total_acquisition_gain"]
        ), 0)

        roi_conservative_y1 = round(
            ((conservative - costs["year_1_total"]) /
             costs["year_1_total"]) * 100, 0
        )
        roi_optimistic_y1 = round(
            ((optimistic - costs["year_1_total"]) /
             costs["year_1_total"]) * 100, 0
        )
        payback_months = round(
            (costs["year_1_total"] / ((conservative + optimistic) / 2)) * 12, 1
        )

        return {
            "firm_size": f"{self.designers} designers",
            "projects_per_year": self.projects,
            "benefits": {
                "design_efficiency": efficiency["equivalent_revenue"],
                "procurement_savings": procurement["total_procurement_savings"],
                "margin_improvement": margins["total_margin_improvement"],
                "client_acquisition": acquisition["total_acquisition_gain"],
            },
            "benefit_range": {
                "conservative": conservative,
                "optimistic": optimistic
            },
            "costs": costs,
            "returns": {
                "roi_conservative_y1_pct": roi_conservative_y1,
                "roi_optimistic_y1_pct": roi_optimistic_y1,
                "payback_months": payback_months,
                "net_benefit_conservative": conservative - costs["year_1_total"],
                "net_benefit_optimistic": optimistic - costs["year_1_total"]
            }
        }

# Run the analysis
model = DesignFirmROIModel(designers=15, projects_per_year=200)
results = model.full_roi_analysis()

print(f"Firm: {results['firm_size']}, {results['projects_per_year']} projects/yr")
print(f"Design Efficiency: ${results['benefits']['design_efficiency']:,.0f}")
print(f"Procurement Savings: ${results['benefits']['procurement_savings']:,.0f}")
print(f"Margin Improvement: ${results['benefits']['margin_improvement']:,.0f}")
print(f"Client Acquisition: ${results['benefits']['client_acquisition']:,.0f}")
print(f"Benefit Range: ${results['benefit_range']['conservative']:,.0f} - ${results['benefit_range']['optimistic']:,.0f}")
print(f"Year 1 Cost: ${results['costs']['year_1_total']:,.0f}")
print(f"ROI Range: {results['returns']['roi_conservative_y1_pct']}% - {results['returns']['roi_optimistic_y1_pct']}%")
print(f"Payback: {results['returns']['payback_months']} months")
Bottom line: A 15-designer firm investing $114,000 in year one can expect annual benefits between $420,000 and $980,000, yielding a payback period of 2-3 months and year-1 ROI of 270-760%. The largest single contributor is design efficiency -- the 4,600+ hours saved annually can either be redirected to additional revenue-generating projects or used to reduce project timelines and improve client satisfaction scores.

Getting Started: Implementation Roadmap

Rolling out AI agents across an interior design practice works best as a phased approach, starting with the tools that deliver the fastest visible wins:

  1. Month 1-2: Material selection automation. Digitize your preferred vendor catalog into a structured database. Deploy the specification matching agent. Designers see immediate time savings on every project.
  2. Month 3-4: Space planning agent. Start with the most common room types (living rooms, bedrooms, offices). Refine scoring weights based on designer feedback. Build a library of approved layout templates.
  3. Month 5-6: Project management integration. Connect procurement tracking to your existing PM tool. Deploy budget monitoring and delivery cascade alerts. Train project managers on the new workflow.
  4. Month 7-8: Visualization pipeline. Set up render automation for your most-used 3D software. Build mood board generation into the client presentation workflow. Integrate style profiling.
  5. Month 9-12: Client management and optimization. Deploy lead scoring and portfolio curation. Activate referral tracking. Begin collecting data to refine all models based on actual project outcomes.

The key principle is that the AI agent augments the designer's creative judgment rather than replacing it. The agent handles the computational and administrative heavy lifting -- evaluating 500 layouts, comparing 80 material options, tracking 40 purchase orders -- so designers can spend their time on the creative and relational work that clients actually hire them for.

Build Your Own AI Agent System

Get the complete implementation playbook with templates, workflows, and step-by-step deployment guides.

Get the Playbook — $19

Not ready to buy? Start with Chapter 1 — free

Get the first chapter of The AI Agent Playbook delivered to your inbox. Learn what AI agents really are and see real production examples.

Get Free Chapter →