APM

>Agent Skill

@datadrivenconstruction/drawing-analyzer

skilldevelopment

Analyze construction drawings to extract dimensions, annotations, symbols, and metadata. Support quantity takeoff and design review automation.

apm::install
$apm install @datadrivenconstruction/drawing-analyzer
apm::skill.md
---
name: "drawing-analyzer"
description: "Analyze construction drawings to extract dimensions, annotations, symbols, and metadata. Support quantity takeoff and design review automation."
homepage: "https://datadrivenconstruction.io"
metadata: {"openclaw": {"emoji": "📑", "os": ["darwin", "linux", "win32"], "homepage": "https://datadrivenconstruction.io", "requires": {"bins": ["python3"]}}}
---
# Drawing Analyzer for Construction

## Overview

Analyze construction drawings (PDF, DWG) to extract dimensions, annotations, symbols, title block data, and support automated quantity takeoff and design review.

## Business Case

Drawing analysis automation enables:
- **Faster Takeoffs**: Extract quantities from drawings
- **Quality Control**: Verify drawing completeness
- **Data Extraction**: Pull metadata for project systems
- **Design Review**: Automated checking against standards

## Technical Implementation

```python
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional, Tuple
import re
import pdfplumber
from pathlib import Path

@dataclass
class TitleBlockData:
    project_name: str
    project_number: str
    sheet_number: str
    sheet_title: str
    discipline: str
    scale: str
    date: str
    revision: str
    drawn_by: str
    checked_by: str
    approved_by: str

@dataclass
class Dimension:
    value: float
    unit: str
    dimension_type: str  # linear, angular, radial
    location: Tuple[float, float]
    associated_text: str

@dataclass
class Annotation:
    text: str
    annotation_type: str  # note, callout, tag, keynote
    location: Tuple[float, float]
    references: List[str]

@dataclass
class Symbol:
    symbol_type: str  # door, window, equipment, etc.
    tag: str
    location: Tuple[float, float]
    properties: Dict[str, Any]

@dataclass
class DrawingAnalysisResult:
    file_name: str
    title_block: Optional[TitleBlockData]
    dimensions: List[Dimension]
    annotations: List[Annotation]
    symbols: List[Symbol]
    scale_factor: float
    drawing_area: Tuple[float, float]
    quality_issues: List[str]

class DrawingAnalyzer:
    """Analyze construction drawings for data extraction."""

    # Common dimension patterns
    DIMENSION_PATTERNS = [
        r"(\d+'-\s*\d+(?:\s*\d+/\d+)?\"?)",  # Feet-inches: 10'-6", 10' - 6 1/2"
        r"(\d+(?:\.\d+)?)\s*(?:mm|cm|m|ft|in)",  # Metric/imperial with unit
        r"(\d+'-\d+\")",  # Compact feet-inches
        r"(\d+)\s*(?:SF|LF|CY|EA)",  # Quantity dimensions
    ]

    # Common annotation patterns
    ANNOTATION_PATTERNS = {
        'keynote': r'^\d{1,2}[A-Z]?$',  # 1A, 12, 5B
        'room_tag': r'^(?:RM|ROOM)\s*\d+',
        'door_tag': r'^[A-Z]?\d{2,3}[A-Z]?$',
        'grid_line': r'^[A-Z]$|^\d+$',
        'elevation': r'^(?:EL|ELEV)\.?\s*\d+',
        'detail_ref': r'^\d+/[A-Z]\d+',
    }

    # Scale patterns
    SCALE_PATTERNS = [
        r"SCALE:\s*(\d+(?:/\d+)?)\s*[\"']\s*=\s*(\d+)\s*['\-]",  # 1/4" = 1'-0"
        r"(\d+):(\d+)",  # 1:100
        r"NTS|NOT TO SCALE",
    ]

    def __init__(self):
        self.results: Dict[str, DrawingAnalysisResult] = {}

    def analyze_pdf_drawing(self, pdf_path: str) -> DrawingAnalysisResult:
        """Analyze a PDF drawing."""
        path = Path(pdf_path)

        all_text = ""
        dimensions = []
        annotations = []
        symbols = []
        quality_issues = []

        with pdfplumber.open(pdf_path) as pdf:
            for page in pdf.pages:
                # Extract text
                text = page.extract_text() or ""
                all_text += text + "\n"

                # Extract dimensions
                page_dims = self._extract_dimensions(text)
                dimensions.extend(page_dims)

                # Extract annotations
                page_annots = self._extract_annotations(text)
                annotations.extend(page_annots)

                # Extract from tables (often contain schedules)
                tables = page.extract_tables()
                for table in tables:
                    symbols.extend(self._parse_schedule_table(table))

        # Parse title block
        title_block = self._extract_title_block(all_text)

        # Determine scale
        scale_factor = self._determine_scale(all_text)

        # Quality checks
        quality_issues = self._check_drawing_quality(
            title_block, dimensions, annotations
        )

        result = DrawingAnalysisResult(
            file_name=path.name,
            title_block=title_block,
            dimensions=dimensions,
            annotations=annotations,
            symbols=symbols,
            scale_factor=scale_factor,
            drawing_area=(0, 0),  # Would need image analysis
            quality_issues=quality_issues
        )

        self.results[path.name] = result
        return result

    def _extract_dimensions(self, text: str) -> List[Dimension]:
        """Extract dimensions from text."""
        dimensions = []

        for pattern in self.DIMENSION_PATTERNS:
            matches = re.findall(pattern, text)
            for match in matches:
                value, unit = self._parse_dimension_value(match)
                if value > 0:
                    dimensions.append(Dimension(
                        value=value,
                        unit=unit,
                        dimension_type='linear',
                        location=(0, 0),
                        associated_text=match
                    ))

        return dimensions

    def _parse_dimension_value(self, dim_text: str) -> Tuple[float, str]:
        """Parse dimension text to value and unit."""
        dim_text = dim_text.strip()

        # Feet and inches: 10'-6"
        ft_in_match = re.match(r"(\d+)'[-\s]*(\d+)?(?:\s*(\d+)/(\d+))?\"?", dim_text)
        if ft_in_match:
            feet = int(ft_in_match.group(1))
            inches = int(ft_in_match.group(2) or 0)
            if ft_in_match.group(3) and ft_in_match.group(4):
                inches += int(ft_in_match.group(3)) / int(ft_in_match.group(4))
            return feet * 12 + inches, 'in'

        # Metric with unit
        metric_match = re.match(r"(\d+(?:\.\d+)?)\s*(mm|cm|m)", dim_text)
        if metric_match:
            return float(metric_match.group(1)), metric_match.group(2)

        # Just a number
        num_match = re.match(r"(\d+(?:\.\d+)?)", dim_text)
        if num_match:
            return float(num_match.group(1)), ''

        return 0, ''

    def _extract_annotations(self, text: str) -> List[Annotation]:
        """Extract annotations from text."""
        annotations = []
        lines = text.split('\n')

        for line in lines:
            line = line.strip()
            if not line:
                continue

            for annot_type, pattern in self.ANNOTATION_PATTERNS.items():
                if re.match(pattern, line, re.IGNORECASE):
                    annotations.append(Annotation(
                        text=line,
                        annotation_type=annot_type,
                        location=(0, 0),
                        references=[]
                    ))
                    break

            # General notes
            if line.startswith(('NOTE:', 'SEE ', 'REFER TO', 'TYP', 'U.N.O.')):
                annotations.append(Annotation(
                    text=line,
                    annotation_type='note',
                    location=(0, 0),
                    references=[]
                ))

        return annotations

    def _extract_title_block(self, text: str) -> Optional[TitleBlockData]:
        """Extract title block information."""
        # Common title block patterns
        patterns = {
            'project_name': r'PROJECT(?:\s*NAME)?:\s*(.+?)(?:\n|$)',
            'project_number': r'(?:PROJECT\s*)?(?:NO|NUMBER|#)\.?:\s*(\S+)',
            'sheet_number': r'SHEET(?:\s*NO)?\.?:\s*([A-Z]?\d+(?:\.\d+)?)',
            'sheet_title': r'SHEET\s*TITLE:\s*(.+?)(?:\n|$)',
            'scale': r'SCALE:\s*(.+?)(?:\n|$)',
            'date': r'DATE:\s*(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})',
            'revision': r'REV(?:ISION)?\.?:\s*(\S+)',
            'drawn_by': r'(?:DRAWN|DRN)\s*(?:BY)?:\s*(\S+)',
            'checked_by': r'(?:CHECKED|CHK)\s*(?:BY)?:\s*(\S+)',
        }

        extracted = {}
        for field, pattern in patterns.items():
            match = re.search(pattern, text, re.IGNORECASE)
            extracted[field] = match.group(1).strip() if match else ''

        # Determine discipline from sheet number
        sheet_num = extracted.get('sheet_number', '')
        discipline = ''
        if sheet_num:
            prefix = sheet_num[0].upper() if sheet_num[0].isalpha() else ''
            discipline_map = {
                'A': 'Architectural', 'S': 'Structural', 'M': 'Mechanical',
                'E': 'Electrical', 'P': 'Plumbing', 'C': 'Civil',
                'L': 'Landscape', 'I': 'Interior', 'F': 'Fire Protection'
            }
            discipline = discipline_map.get(prefix, '')

        return TitleBlockData(
            project_name=extracted.get('project_name', ''),
            project_number=extracted.get('project_number', ''),
            sheet_number=sheet_num,
            sheet_title=extracted.get('sheet_title', ''),
            discipline=discipline,
            scale=extracted.get('scale', ''),
            date=extracted.get('date', ''),
            revision=extracted.get('revision', ''),
            drawn_by=extracted.get('drawn_by', ''),
            checked_by=extracted.get('checked_by', ''),
            approved_by=''
        )

    def _parse_schedule_table(self, table: List[List]) -> List[Symbol]:
        """Parse schedule table to extract symbols/elements."""
        symbols = []

        if not table or len(table) < 2:
            return symbols

        # First row is usually headers
        headers = [str(cell).lower() if cell else '' for cell in table[0]]

        # Find key columns
        tag_col = next((i for i, h in enumerate(headers) if 'tag' in h or 'mark' in h or 'no' in h), 0)
        type_col = next((i for i, h in enumerate(headers) if 'type' in h or 'size' in h), -1)

        for row in table[1:]:
            if len(row) > tag_col and row[tag_col]:
                tag = str(row[tag_col]).strip()
                symbol_type = str(row[type_col]).strip() if type_col >= 0 and len(row) > type_col else ''

                if tag:
                    props = {}
                    for i, header in enumerate(headers):
                        if i < len(row) and row[i]:
                            props[header] = str(row[i])

                    symbols.append(Symbol(
                        symbol_type=symbol_type or 'unknown',
                        tag=tag,
                        location=(0, 0),
                        properties=props
                    ))

        return symbols

    def _determine_scale(self, text: str) -> float:
        """Determine drawing scale factor."""
        for pattern in self.SCALE_PATTERNS:
            match = re.search(pattern, text, re.IGNORECASE)
            if match:
                if 'NTS' in match.group(0).upper():
                    return 0  # Not to scale

                if '=' in match.group(0):
                    # Imperial: 1/4" = 1'-0"
                    return self._parse_imperial_scale(match.group(0))
                else:
                    # Metric: 1:100
                    return 1 / float(match.group(2))

        return 1.0  # Default

    def _parse_imperial_scale(self, scale_text: str) -> float:
        """Parse imperial scale to factor."""
        match = re.search(r'(\d+)(?:/(\d+))?\s*["\']?\s*=\s*(\d+)', scale_text)
        if match:
            numerator = float(match.group(1))
            denominator = float(match.group(2)) if match.group(2) else 1
            feet = float(match.group(3))
            inches_per_foot = (numerator / denominator)
            return inches_per_foot / (feet * 12)
        return 1.0

    def _check_drawing_quality(self, title_block: TitleBlockData,
                                dimensions: List, annotations: List) -> List[str]:
        """Check drawing for quality issues."""
        issues = []

        if title_block:
            if not title_block.project_number:
                issues.append("Missing project number in title block")
            if not title_block.sheet_number:
                issues.append("Missing sheet number")
            if not title_block.scale:
                issues.append("Missing scale indication")
            if not title_block.date:
                issues.append("Missing date")

        if len(dimensions) == 0:
            issues.append("No dimensions found - verify drawing content")

        # Check for typical construction notes
        note_types = [a.annotation_type for a in annotations]
        if 'note' not in note_types:
            issues.append("No general notes found")

        return issues

    def generate_drawing_index(self, results: List[DrawingAnalysisResult]) -> str:
        """Generate drawing index from multiple analyzed drawings."""
        lines = ["# Drawing Index", ""]
        lines.append("| Sheet | Title | Discipline | Scale | Rev |")
        lines.append("|-------|-------|------------|-------|-----|")

        for result in sorted(results, key=lambda r: r.title_block.sheet_number if r.title_block else ''):
            if result.title_block:
                tb = result.title_block
                lines.append(f"| {tb.sheet_number} | {tb.sheet_title} | {tb.discipline} | {tb.scale} | {tb.revision} |")

        return "\n".join(lines)

    def generate_report(self, result: DrawingAnalysisResult) -> str:
        """Generate analysis report for a drawing."""
        lines = ["# Drawing Analysis Report", ""]
        lines.append(f"**File:** {result.file_name}")

        if result.title_block:
            tb = result.title_block
            lines.append("")
            lines.append("## Title Block")
            lines.append(f"- **Project:** {tb.project_name}")
            lines.append(f"- **Project No:** {tb.project_number}")
            lines.append(f"- **Sheet:** {tb.sheet_number}")
            lines.append(f"- **Title:** {tb.sheet_title}")
            lines.append(f"- **Discipline:** {tb.discipline}")
            lines.append(f"- **Scale:** {tb.scale}")
            lines.append(f"- **Date:** {tb.date}")
            lines.append(f"- **Revision:** {tb.revision}")

        lines.append("")
        lines.append("## Content Summary")
        lines.append(f"- **Dimensions Found:** {len(result.dimensions)}")
        lines.append(f"- **Annotations Found:** {len(result.annotations)}")
        lines.append(f"- **Symbols/Elements:** {len(result.symbols)}")

        if result.quality_issues:
            lines.append("")
            lines.append("## Quality Issues")
            for issue in result.quality_issues:
                lines.append(f"- ⚠️ {issue}")

        if result.symbols:
            lines.append("")
            lines.append("## Elements Found")
            for symbol in result.symbols[:20]:
                lines.append(f"- {symbol.tag}: {symbol.symbol_type}")

        return "\n".join(lines)
```

## Quick Start

```python
# Initialize analyzer
analyzer = DrawingAnalyzer()

# Analyze a drawing
result = analyzer.analyze_pdf_drawing("A101_Floor_Plan.pdf")

# Check title block
if result.title_block:
    print(f"Sheet: {result.title_block.sheet_number}")
    print(f"Title: {result.title_block.sheet_title}")
    print(f"Scale: {result.title_block.scale}")

# Review extracted data
print(f"Dimensions: {len(result.dimensions)}")
print(f"Annotations: {len(result.annotations)}")
print(f"Symbols: {len(result.symbols)}")

# Check quality
for issue in result.quality_issues:
    print(f"Issue: {issue}")

# Generate report
report = analyzer.generate_report(result)
print(report)
```

## Dependencies

```bash
pip install pdfplumber
```