Skip to content

Drawing Viewer — Consolidated Technical Specification

Status: consolidated from five source design docs Feature scope: full DWG/DXF/PDF viewer used by PMs, Field Foremen, and Estimators for progress tracking, zone markup, and entity annotation Frontend feature module: apps/web/src/features/drawing-viewer/ Backend services: apps/api/service/drawing*.go, apps/api/parser/*.py Linear epic: PIN-146

Source documents

This document consolidates the following source specs (kept for history in docs/superpowers/specs/ and, for cross-project material, in the sibling AI-prototype repo):

Date Spec Scope
2026-04-11 2026-04-11-drawing-viewer-progress-tracker-design.md Feature definition, data model, API, UX
2026-04-12 2026-04-12-multi-format-drawing-ingest-design.md DWG + PDF ingest on top of DXF pipeline
2026-04-13 2026-04-13-entity-annotation-design.md Entity hit-test, layout-unit coords, entity-linked annotations
2026-04-22 2026-04-22-drawing-viewer-v1-solid-base-design.md v1 rebuild: binary scene proto, SceneCanvas, rules engine, iPad gate
2026-04-23 2026-04-23-drawing-viewer-bug-fixes-paperspace-expansion-design.md Zone polygon hotfix, bounds-trim, paper-space viewport expansion, text rendering
ported AI-prototype/docs/content/workflows/06-takeoff.md + epics/epic-05-estimate-takeoff.md + workflows/maps/06-takeoff-map.md + sequences/takeoff-to-estimate.md Ported viewer-core additions: measurement tools, scale calibration, sheet completion tracking, addendum comparison, repeating groups, AI equipment detection. Estimator-specific items (spec palette, auto-fittings, SMACNA, live cost) intentionally excluded — see §30.

Where specs disagree, the latest wins (e.g., the 2026-04-23 paper-space expansion supersedes the 2026-04-13 server-side raster entity-index approach).


1. Overview & Users

A collaborative construction progress tracker built on top of architectural drawings. "Google Docs for architectural plans" — the underlying drawing never changes, but status zones, annotations, and history are collaborative and versioned.

Replaces Pinley's current workflow where field foremen email marked-up PDFs to PMs who manually reconcile progress.

Primary workflows by role

Field Foremen (iPad, primary v1 audience). Open a drawing on site, find their zone ("East Wing Ductwork 8th Floor"), tap a big status button, optionally add a short note.

Project Managers (desktop, primary v1). Upload drawings per project, draw polygon zones, review rollup across zones, investigate blockers via history timeline.

Estimators (desktop, lower v1 priority). View pre-award during bidding, draw zones for takeoff scoping (future integration).


2. Scope

In scope for v1

  • Input formats: DXF, DWG (converted to DXF via ODA File Converter, LibreDWG fallback), PDF (rasterized per page)
  • Upload → server-side parse → extract layouts + layers + per-entity geometry
  • CAD-accurate browser rendering via WebGL: per-entity color, linetype (solid/dashed/dotted/hidden/center), lineweight
  • MText/Text rendering via baked FreeType glyph outlines (v1), migrating to Troika Text meshes in the paper-space expansion update
  • Hatches (solid → filled Three.Mesh; pattern → tessellated LineSegments)
  • Arcs / circles stored compact; tessellated JS-side per zoom
  • Layer visibility toggle; per-layer auto-classification (HVAC/PLUMBING/ELEC/STRUCT/ARCH/MISC) via DB-backed rules engine
  • Polygon zone drawing (PM) + 5-state status (all roles) with audit trail
  • Per-zone history timeline + per-zone threaded comments
  • Entity-linked annotations (click a duct → pin a note to that exact instance)
  • Free-form annotations: pin, text, arrow, rectangle
  • Multi-page/multi-layout drawings (Model + paper-space layouts; one layout per PDF page)
  • Paper-space viewport expansion — content visible through VIEWPORTs in paper-space layouts is projected into paper-space coords, so what users see matches the published PDF
  • Optimistic concurrency for simultaneous edits (expected_status_version, 409 on conflict)
  • Idempotency for flaky mobile connections (client_request_id on status update)
  • Thumbnails (300 px PNG per layout) for drawing list

Target runtimes

  • Desktop Chrome, 2020+ MacBooks
  • iPad Safari, M1+ (client-required; upload caps and rendering budgets sized for this device)

Deferred to v2 (tracked in TODO)

  • Visual diff timeline — scrub back through time to see drawing state at any point (Google Docs version history style)
  • Status change notifications — email/push when zones change status
  • Admin UI for classification rules engine (v1 has schema + API, no UI)
  • User-triggered misclassification feedback → draft rules
  • IndexedDB persistence for offline edit queue (v1: in-memory only, lost on reload)
  • Annotation migration algorithm on re-upload (v1: handle-exact match; fuzzy spatial match deferred)
  • SDF text rendering (v1: outline polygons or Troika glyph meshes; SDF if payload dominates)
  • Admin UI to override firm-level quotas

Deferred indefinitely

  • Real-time multi-user cursors / live presence
  • AI-assisted auto-zoning from layers
  • Measurement/takeoff tools (separate epic PIN-143)
  • Mobile native apps (web-first on mobile)
  • Inline DWG editing or round-trip export
  • Android / pre-2020 laptop support
  • PNG/JPG plan scan ingest (could layer on the PDF raster path later)
  • Export/print of rendered scene

3. Architecture

3.1 Frontend — isolated feature module

apps/web/src/features/drawing-viewer/
├── index.js                         # Public exports
├── DrawingViewerPage.jsx            # Full-viewport route component
├── components/
│   ├── SceneCanvas.jsx              # Three.js WebGL renderer (v1)
│   ├── PdfCanvas.jsx                # Raster PDF renderer
│   ├── LayerPanel.jsx               # Layer sidebar, discipline-grouped, tri-state toggle
│   ├── ZonePanel.jsx                # Zone list, status filters, detail, history
│   ├── ProgressOverlay.jsx          # SVG zones colored by status (model space)
│   ├── PaperspaceZoneOverlay.jsx    # SVG zones in paper-space coords
│   ├── AnnotationOverlay.jsx        # SVG pins/text/arrows
│   ├── EntityAnnotationOverlay.jsx  # Pin markers tied to scene instance_id
│   ├── EntityAnnotationModal.jsx    # Form when user clicks an entity
│   ├── BatchEntityAnnotationModal.jsx
│   ├── AnnotationTools.jsx          # Free-form pin/text/arrow/rectangle toolbar
│   ├── AnnotationLabelModal.jsx     # In-app replacement for window.prompt
│   ├── ConfirmModal.jsx             # In-app replacement for window.confirm
│   ├── StatusControls.jsx           # Status buttons + note input
│   ├── ZoneDrawingTool.jsx          # Polygon drawing (model)
│   ├── PaperspaceZoneDrawingTool.jsx
│   ├── EntityClickCapture.jsx       # Overlay that forwards clicks to hit-test
│   ├── EntityMultiSelectTool.jsx    # Rect-drag multi-select
│   ├── HistoryTimeline.jsx          # Audit log + time-scrub affordance
│   ├── TimeScrubber.jsx
│   ├── CommentsPanel.jsx            # Zone comments
│   ├── UploadDropzone.jsx           # DXF/DWG/PDF upload
│   └── ProgressBar / ProcessingBanner
├── lib/
│   ├── sceneDecoder.js              # Gunzip + protobuf decode + Flatbush index build
│   ├── sceneRenderer.js             # Scene → Three.Group (LineSegments, Meshes, Text)
│   ├── cameraService.js             # Orthographic camera pan/zoom, world↔screen
│   ├── layerClassifier.js           # Client-side display grouping fallback
│   ├── layerGroups.js               # Tree-build for LayerPanel
│   ├── zoneGeometry.js              # Polygon math (hit-testing, area, bbox)
│   └── dxfColors.js                 # AutoCAD ACI color index
├── api/
│   └── drawingService.js            # ConnectRPC wrapper: GC.DrawingService.*
├── stores/
│   └── drawingStore.js              # nanostores: drawing, layers, zones, mode, $viewBox
└── README.md

Route: /projects/:projectId/drawings/:drawingId — dedicated route that replaces ApplicationLayout with its own full-viewport shell.

Modularity guarantees (enforced in code review):

  1. Nothing outside features/drawing-viewer/ imports from inside except via index.js.
  2. All ConnectRPC calls go through api/drawingService.js — swap backends without touching components.
  3. SceneCanvas is a pure viewer; zone/status/annotation overlays are injected as children.
  4. Could be extracted to a separate package if micro-frontend needed.

3.2 Backend — Go services following the existing pattern

apps/api/
├── model/
│   ├── drawing.go
│   ├── drawing_layout.go
│   ├── drawing_zone.go
│   ├── drawing_zone_status_event.go
│   ├── drawing_zone_comment.go
│   ├── drawing_annotation.go
│   ├── upload_session.go
│   └── layer_classification_rule.go
├── repository/
│   ├── drawing.go, drawing_layout.go, drawing_zone.go,
│   │   drawing_zone_status_event.go, drawing_annotation.go
├── service/
│   ├── drawing.go
│   ├── drawing_zone.go                 # UpdateStatus orchestration
│   ├── drawing_annotation.go
│   └── drawing_parse_worker.go         # Queue consumer (Redis rmq)
├── server/
│   └── drawing.go                      # ConnectRPC handlers
├── parser/
│   ├── drawing_worker.py               # v1 orchestrator (one invocation per ingest)
│   ├── parse_drawing.py                # legacy / fallback
│   ├── scene_builder.py                # Emits DrawingScene protobuf per layout
│   ├── viewport_expansion.py           # Paper-space VIEWPORT resolution
│   ├── classifier.py                   # Layer classification rules engine (Python side)
│   ├── render_layout.py                # 300-px thumbnail PNG + legacy raster
│   └── tests/
└── internal/
    └── blob_upload/                    # Local-disk blob backend + signed URLs (dev)

Pattern: server → service → repository → model. Models registered in migration.RegisterModels(). Permissions seeded via seedModulePermissions().

3.3 End-to-end ingest pipeline

UploadSession.CreateUpload → pre-signed blob PUT
   ↓ FinalizeUpload
   ↓ detect format from filename extension
   ├── .dxf  → parse_worker (single path)
   ├── .dwg  → ODA/LibreDWG → .dxf → parse_worker
   └── .pdf  → pdf_worker: per-page raster + per-page DrawingLayout rows
   ↓
drawing_worker.py (one invocation, orchestrates):
   ↓ loads DXF via ezdxf (or reads stored converted .dxf on re-parse)
   ↓ enumerates layouts (Model + paper-space)
   ↓ for each layout:
   │   - viewport_expansion.expand_paperspace_to_entities (paper-space only)
   │   - scene_builder emits DrawingScene protobuf
   │   - tessellates arcs/circles/hatches/text
   │   - classifier assigns discipline_code + segment_group per layer
   │   - thumbnail PNG (300 px) via matplotlib
   ↓ stdout manifest JSON {layout_id, kind, path, sha256, bytes, version stamps}
   ↓
drawing_parse_worker.go:
   ↓ verifies SHA256 per artifact
   ↓ moves all files to blob in one transaction
   ↓ upserts DB rows
   ↓ transitions Drawing.processing_status: processing → ready

4. Data Model

All tables include organization_id (multi-tenant) and soft-delete deleted_at.

4.1 Drawing

Field Type Notes
id uint32 PK
organization_id uuid Multi-tenant
project_id uuid FK Project
name string Human label
discipline enum mechanical / electrical / plumbing / architectural / other
source_format int32 enum DrawingSourceFormat DXF / DWG / PDF
source_file_id uint32 FK File Original uploaded file
dxf_file_id uint32 FK File Synonym of source_file_id on pre-existing DXF rows
rendered_file_id *uint32 FK File Converted .dxf (DWG inputs) or raster manifest (PDF); nullable
converter_used string oda / libredwglocked for DWG handle stability (§15)
processing_status int32 enum PROCESSING (1), READY (2), FAILED (3)
processing_error text Populated on FAILED
parser_version string Which parser produced layer_metadata
scene_schema_version int32 Bumped on protobuf-shape changes
renderer_version int32 Bumped on geometry/text output changes
layer_metadata jsonb Raw layers + auxiliary info (PDF page URLs, warnings)
page_count int32 1 for DXF/DWG; N for PDF
version int32 Optimistic lock for metadata edits
version_number int32 Revision counter (re-upload bumps)
parent_drawing_id *uint32 FK to previous revision
is_archived bool Set on re-upload if user chose "Archive old"
uploaded_by_user_id uuid
uploaded_at timestamp

Indexes: (organization_id, project_id), (organization_id, processing_status), (organization_id, deleted_at), (renderer_version), (scene_schema_version).

4.2 DrawingLayout

One DXF can have Model + multiple paper-space layouts. Each PDF page is one layout. Zones and annotations attach to a specific layout.

Field Type Notes
id uint32 PK
drawing_id uint32 FK
layout_name string "Model", "SM-8-1", "Page 1"
layout_index int32 0-based
is_model_space bool
units string "feet", "inches", "mm", "page"
extents jsonb {min_x, min_y, max_x, max_y}
entities_path string Blob path to per-layout entity index (entity-annotation path, 2026-04-13)
entities_url string Signed read URL, populated on GetDrawing
entity_count int32

4.3 DrawingZone

Field Type Notes
id uint32 PK
drawing_id, layout_id FK
name, description string
polygon_points jsonb text Canonical form: [{"x":…,"y":…}, …]. Read path accepts legacy [[x,y],…] and normalizes on write. Coords are in layout units (paperspace inches) or DXF world units depending on layout type.
bbox_min_x/y, bbox_max_x/y float64 For viewport filtering — in layout units.
current_status int32 enum DrawingZoneStatus Denormalized from latest event
status_version int32 Optimistic lock, incremented on every status change
version int32 Optimistic lock for zone polygon/name edits

Polygon validation: ≥3 points, non-empty, all numeric.

Zone status enum:

DrawingZoneStatus:
  UNSPECIFIED  = 0
  NOT_STARTED  = 1    // gray outline, no fill
  IN_PROGRESS  = 2    // blue, 20% fill
  COMPLETE     = 3    // green, 20% fill
  BLOCKED      = 4    // red, 20% fill, red border — hard stop
  NEEDS_REVIEW = 5    // amber, 20% fill, amber border — PM attention

BLOCKED and NEEDS_REVIEW are explicit, not a single "issue" state — they roll up differently in reporting.

4.4 DrawingZoneStatusEvent (immutable audit log)

Field Type Notes
id uuid PK
zone_id FK Soft-referenced (history queries include deleted zones)
actor_user_id FK
previous_status, new_status enum
zone_version_after int32 status_version of zone after this event
client_request_id string Idempotency key
note string Optional
created_at timestamp

Indexes: (zone_id, created_at DESC), UNIQUE(zone_id, client_request_id).

4.5 DrawingZoneComment

Threaded comments per zone (top-level + 1 reply depth in v1).

Field Type Notes
id uint32 PK
zone_id FK
parent_id *uint32 Null for top-level; FK self for replies
author_user_id FK
author_name string Snapshot for display
body text

4.6 DrawingAnnotation

Field Type Notes
id uint32 PK
drawing_id, layout_id FK
type enum DrawingAnnotationType PIN (1), TEXT (2), ARROW (3), RECTANGLE (4). Entity-linked pins use type=PIN with geometry.kind = 'entity_pin'.
geometry jsonb string Type-specific shape — see §11
label string
color string Hex
entity_handle string DXF handle (empty for free-placed annotations)
entity_layer, entity_type string Snapshot for display + fallback relink
entity_anchor_point string JSON {x,y} in layout units
entity_id uint32 Canonical FK to scene.entities[].instance_id post-2026-04-23. Overwritten per-parse via (layout_id, handle) → instance_id mapping table
version int32 Optimistic lock

4.7 UploadSession

Pre-signed upload tokens.

Field Type Notes
id uuid PK
project_id, organization_id FK
original_filename string
blob_path string uploads/{org_id}/{project_id}/{upload_id}.{ext}
expected_size int64 Cross-checked on finalize
content_type string
expires_at timestamp 1-hour default
finalized_at *timestamp

Orphan cleanup: daily job deletes blobs for sessions older than 24h with no finalize.

4.8 LayerClassificationRule (rules engine — §9)

CREATE TABLE layer_classification_rule (
  id                     uuid PRIMARY KEY,
  version                int NOT NULL DEFAULT 1,
  firm_id                uuid NULL,            -- NULL = global
  project_id             uuid NULL,            -- NULL = applies to all projects in scope
  pattern_regex          text NOT NULL,       -- e.g. '^ACM-H-.*'
  discipline_code        text NOT NULL,       -- 'HVAC', 'PLUMBING', 'ELEC', 'STRUCT', 'ARCH', 'MISC'
  segment_group          text NULL,           -- 'SUPPLY', 'RETURN', 'EXHAUST', 'OUTSIDE', 'TRANSFER'
  sort_order             int NOT NULL,
  enabled                boolean NOT NULL DEFAULT true,
  classification_source  text NOT NULL,       -- 'seed' | 'manual' | 'feedback'
  created_by             uuid NOT NULL REFERENCES "user"(id),
  created_at             timestamptz NOT NULL DEFAULT now(),
  updated_at             timestamptz NOT NULL DEFAULT now(),
  superseded_by_id       uuid NULL REFERENCES layer_classification_rule(id)  -- append-only
);

4.9 Proto enums (excerpt)

enum DrawingSourceFormat {
  DRAWING_SOURCE_FORMAT_UNSPECIFIED = 0;
  DRAWING_SOURCE_FORMAT_DXF = 1;
  DRAWING_SOURCE_FORMAT_DWG = 2;
  DRAWING_SOURCE_FORMAT_PDF = 3;
}

enum DrawingClassificationSource {
  DRAWING_CLASSIFICATION_SOURCE_UNSPECIFIED = 0;
  DRAWING_CLASSIFICATION_SOURCE_RULE = 1;
  DRAWING_CLASSIFICATION_SOURCE_FALLBACK = 2;
  DRAWING_CLASSIFICATION_SOURCE_MANUAL_OVERRIDE = 3;
}

5. API / ConnectRPC

Single proto: proto/api/v1/drawing.proto. Single Go service impl.

5.1 Service surface

DrawingService
  # Upload — server-issued token, direct-to-blob, finalize
  CreateUpload(project_id, filename, size, content_type)
    → { upload_id, blob_upload_url, blob_path }
  FinalizeUpload(upload_id, name, discipline, idempotency_key?)
    → Drawing
  RetryDrawingParse(drawing_id) → Drawing

  # Drawing CRUD
  GetDrawing(drawing_id) → Drawing + layouts + blob_read_url (15-min signed)
  GetDrawings(project_id, pagination) → Drawing[]
  EditDrawing(drawing_id, expected_version, ...) → Drawing
  DeleteDrawing(drawing_id) → empty

  # Layouts (read-only)
  GetDrawingLayouts(drawing_id) → DrawingLayout[] (with entities_url, thumb_url, scene_url)

  # Zones
  AddDrawingZone(drawing_id, layout_id, name, polygon, ...) → DrawingZone
  GetDrawingZones(
    drawing_id, layout_id?, include_geometry=false, status_filter?, bbox?
  ) → DrawingZone[]  (geometry omitted by default; batch fetch below)
  GetDrawingZoneGeometry(zone_ids[]) → { zone_id → polygon_points }[]
  EditDrawingZone(zone_id, expected_version, ...) → DrawingZone
  UpdateDrawingZoneStatus(
    zone_id, new_status, expected_status_version, client_request_id, note?
  ) → { zone, event }
  DeleteDrawingZone(zone_id) → empty

  # Zone status history
  GetDrawingZoneStatusHistory(zone_id, cursor?, limit) → { events, next_cursor }

  # Zone comments
  AddDrawingZoneComment(zone_id, parent_id?, body) → comment
  GetDrawingZoneComments(zone_id) → comments[]
  DeleteDrawingZoneComment(id) → empty

  # Annotations
  AddDrawingAnnotation(drawing_id, layout_id, type, geometry, label, color, entity_*) → DrawingAnnotation
  GetDrawingAnnotations(drawing_id, layout_id) → DrawingAnnotation[]
  EditDrawingAnnotation(id, expected_version, ...) → DrawingAnnotation
  DeleteDrawingAnnotation(id) → empty

5.2 Secure read path

Blob files are not public. Client gets bytes via short-lived signed URLs.

  • GetDrawing returns blob_read_url signed for 15 minutes.
  • Server authorizes via drawing_id → project_id → organization_id chain; cross-org/cross-project IDs return NotFound (never PermissionDenied — no info leak).
  • Client refetches GetDrawing if URL expires mid-session (rare; 15 min covers normal viewer use).

5.3 Upload flow

V1 uses a single pre-signed PUT (not resumable). Real drawings in the 13-80 MB range complete in seconds on typical office connections. Resumable upload is v2 if field uploads become common.

  1. Client: CreateUpload(project_id, "plan.dxf", 13MB, "application/dxf")
  2. Server: validates size (max 100 MB per §10.2, 80 MB in 2026-04-11 spec — enforced as 100 MB), content type (application/dxf, application/octet-stream, application/acad, application/pdf), project access. Rejects extensions outside {dxf, dwg, pdf}. Creates UploadSession with 1-hr pre-signed Azure Blob PUT URL. Returns {upload_id, blob_upload_url, blob_path}.
  3. Client: PUTs file directly to blob_upload_url (progress via fetch/XHR).
  4. Client: FinalizeUpload(upload_id, name, discipline, idempotency_key) → server:
  5. Validates upload_id exists, not expired, not already finalized
  6. HEAD request on blob: verifies exists, size matches, path matches session
  7. Detects format from extension; for DWG schedules ODA conversion; for PDF schedules per-page raster
  8. Moves blob to permanent path drawings/{org_id}/{project_id}/{drawing_id}.{ext}
  9. Creates File row + Drawing row with processing_status=PROCESSING
  10. Enqueues parse job (Redis rmq)
  11. Marks upload session finalized
  12. Background worker: parses, writes DrawingLayout + layer metadata, transitions processing_status=READY or FAILED + processing_error.
  13. Client polls GetDrawing every 2s while processing_status=PROCESSING (SSE is a v2 improvement).

5.4 UpdateDrawingZoneStatus contract (critical correctness path)

  • If expected_status_version doesn't match current → FailedPrecondition (409). Client refetches + re-prompts.
  • If client_request_id exists for this zone with same payload → return existing event + zone (idempotent).
  • If client_request_id exists with different payloadInvalidArgument.
  • If new_status == current_status → no-op: return current zone without logging a new event. Prevents timeline noise.
  • Transaction:
  • UPDATE drawing_zones SET current_status=?, status_version=status_version+1 WHERE id=? AND status_version=? AND deleted_at IS NULL
  • If 0 rows affected → abort, return FailedPrecondition
  • INSERT INTO drawing_zone_status_events (..., zone_version_after=new_status_version, ...)
  • Commit. DrawingZoneStatusEvent is the audit source of truth — no dual write.

5.5 Delete semantics (soft-delete cascade)

Target Effect
DeleteDrawing(id) Soft-delete Drawing + cascade soft-delete Layouts / Zones / Annotations. Status events preserved (immutable). Blob files preserved 90 days then GC'd.
DeleteDrawingZone(id) Soft-delete Zone. Status events preserved; queries use zone_id even if zone.deleted_at IS NOT NULL.
DeleteDrawingAnnotation(id) Soft-delete.

All list RPCs filter deleted_at IS NULL by default. GetDrawing returns the record even if soft-deleted (with flag) so history can resolve names. GetDrawingZoneStatusHistory inlines the zone's last-known name so renderers don't need a second fetch.

Restore is not in v1 scope.

5.6 Audit logging strategy

No dual-write to generic AuditLogEntry for status changes. DrawingZoneStatusEvent is the full, queryable domain audit trail.

AuditLogEntry IS used for coarser operations without a per-entity event table:

  • drawing.uploaded (by user)
  • drawing.deleted
  • drawing.zone.deleted
  • drawing.annotation.deleted
  • Rule-engine: upload, converter path chosen, re-render trigger, version bumps used, scene download

6. Ingest pipeline — three format paths

6.1 DXF (baseline)

  • File uploaded → blob stored as .dxf
  • drawing_worker.py loads via ezdxf, emits layers + layouts + per-layout scene protobuf + thumbnail PNG
  • Frontend renders via SceneCanvas (Three.js WebGL)

6.2 DWG → DXF

  • File uploaded → blob stored as .dwg
  • Parse worker invokes ODA File Converter via ezdxf.addons.odafc.readfile() (shells out to the ODA binary).
  • Fallback: on ODA failure, try LibreDWG's dwg2dxf. If both fail → DRAWING_UNSUPPORTED_FORMAT.
  • Converter lock per drawing (§15): Drawing.converter_used records which converter succeeded first; all re-renders use the same one — never silently switch, because ODA and LibreDWG emit different DXF handle sequences and a swap breaks every entity-linked annotation.
  • Persist the converted .dxf: for DWG inputs, the converted .dxf is stored at drawings/{drawing_id}/rendered.dxf. Re-renders read the stored DXF, never reconvert — handles are guaranteed stable across renderer_version bumps.
  • From the converted DXF forward, it's the DXF path.

Why ODA + not LibreDWG primary:

  • AutoCAD R2018+ is non-negotiable (real files).
  • LibreDWG reliability on modern DWGs is weak per the GNU project's own docs.
  • ODA File Converter is free-to-use, CLI-friendly, has a first-class ezdxf wrapper.
  • Licensing is EULA-click for install; not redistributed — fine for Docker image (ACCEPT_EULA=1).

6.3 PDF

  • File uploaded → blob stored as .pdf
  • Parse worker invokes parse_drawing.py in PDF mode (pypdfium2):
  • Extract page count + page dimensions in user units
  • Render each page to PNG at 150 DPI into per-page blobs
  • Emit one DrawingLayout per page: LayoutName = "Page N", is_model_space=false, units="page", extents in pixel dims
  • Layers empty (PDFs don't have meaningful layers in this UX)
  • Frontend: when source_format == PDF, swap SceneCanvas for PdfCanvas — loads the per-page PNGs and overlays the existing SVG zone layer. Pan/zoom via CSS transform; viewBox synced to image bbox.
  • Zones, annotations, status flow — all identical to DXF.

Why raster PDF + not vector: vector PDF→SVG is lossy and brittle on CAD-exported PDFs (gaps in pdf2svg, mutool convert). Raster PNG per page renders anywhere, zone-overlay math is identical to DXF. Tradeoff: no per-layer visibility toggle (the Layers panel shows "Page 1", "Page 2" as toggles). Vector PDF extraction can come later without schema changes.


7. Parsing + scene building

7.1 Pipeline invariants

  • One Python invocation per ingest (drawing_worker.py). Replaces multi-script parse + render split — eliminates 3× DXF loads + 3× DWG converts + parse/render drift risk + orphan artifact cleanup.
  • State machine: uploaded → converting → parsing → rendering → complete | failed. Any transition failure → failed with structured error_code + error_detail. No partial complete state.
  • Bounds are recomputed from entities. DXF header EXTMIN/EXTMAX is ignored — frequently stale in real files.
  • Handles normalized to UPPERCASE hex on write.
  • Per-drawing file lock prevents concurrent render of the same drawing.
  • Final stdout is a structured manifest JSON (stderr is for logs only). Go worker verifies SHA256 per artifact; moves all files to blob in one transaction; upserts DB rows.

7.2 Scene protobuf (proto/api/v1/drawing_scene.proto)

message DrawingScene {
  uint32 scene_schema_version = 1;
  uint32 renderer_version     = 2;
  uint32 layout_id            = 3;
  string layout_name          = 4;
  Bounds bounds               = 5;   // fit-on-load target; may be trimmed
  repeated Layer    layers    = 6;
  repeated Linetype linetypes = 7;
  repeated Entity   entities  = 8;   // array order = render z-order
  Bounds full_bounds          = 9;   // unclipped extents for Zoom Extents (§13)
}

message Bounds { double min_x = 1; double min_y = 2; double max_x = 3; double max_y = 4; }

message Layer {
  string name = 1;
  uint32 color_argb = 2;
  uint32 default_linetype_idx = 3;
  double default_lineweight_mm = 4;
  string discipline_code = 5;           // "HVAC", "PLUMBING", ...
  string segment_group = 6;             // "SUPPLY", "RETURN", null-ok
  DrawingClassificationSource classification_source = 7;
  string matched_rule_id = 8;
  uint32 matched_rule_version = 9;
}

message Linetype { string name = 1; repeated double pattern = 2; }

message Entity {
  string handle = 1;                    // DXF handle, UPPERCASE hex (may be empty for virtual children)
  uint32 layer_idx = 2;
  uint32 color_argb = 3;                // 0 = ByLayer
  uint32 linetype_idx = 4;              // 0xFFFFFFFF = ByLayer
  double lineweight_mm = 5;             // 0 = ByLayer
  uint32 instance_id = 6;               // scene-unique monotonic id (annotation FK; §11)
  oneof primitive {
    Polyline   polyline  = 10;
    Circle     circle    = 11;
    Arc        arc       = 12;
    Hatch      hatch     = 13;
    TextBlock  text      = 14;
  }
}

message Polyline { repeated float xy = 1 [packed = true]; bool closed = 2; }
message Circle   { float cx = 1; float cy = 2; float r = 3; }
message Arc      { float cx = 1; float cy = 2; float r = 3; float start_rad = 4; float end_rad = 5; }
message Hatch    { repeated HatchLoop loops = 1; bool solid = 2; repeated float pattern_segments = 3 [packed = true]; }
message HatchLoop{ repeated float xy = 1 [packed = true]; bool is_outer = 2; }

message TextBlock {
  reserved 1; reserved "glyph_outlines";  // old MVP placeholder, never emitted
  float x = 2;                  // anchor point, bounds-local coords
  float y = 3;
  float height = 4;             // drawing units
  float rotation_rad = 5;
  string text = 6;              // plain string (MTEXT flattened via plain_text())
  TextJustifyH justify_h = 7;
  TextJustifyV justify_v = 8;
  float width_factor = 9;       // horizontal stretch, default 1.0
  float oblique = 10;           // italic slant, radians, default 0
  float bbox_w = 11;            // precomputed text width (drawing units; Flatbush)
  float bbox_h = 12;            // precomputed text height
}

7.3 Coordinate precision

float32 + bounds-local. Python subtracts bounds.min_{x,y} from every coordinate before packing. JS consumes either in local space or transforms the camera once. float32 is sufficient for metre-scale drawings (~1 ppm at 1 km); bounds-local guards the worst case (coords near projected-CRS origin).

Phase 0 validated this on real drawings.

7.4 Tessellation strategy

  • Arc / Circle: compact storage (center + radius + angles). JS tessellates per zoom level. Crisp at any zoom without payload inflation.
  • Splines: rare in MEP; Python tessellates to Polyline. Avoids porting NURBS math to JS.
  • Hatches (solid): outer/inner loops with is_outer flag, rendered as Three.Mesh via ShapeGeometry (outer Shape + hole Shapes).
  • Hatches (pattern): Python tessellates pattern into LineSegments on pattern_segments. Pattern hatches are rare in MEP; unified line rendering keeps JS simple.
  • SOLID entities: emitted as proto.hatch with solid=true, single outer loop — filled arrowheads, flow arrows, filled flanges render correctly.
  • Text: v1 initially used FreeType glyph outlines baked as Polyline per glyph. The 2026-04-23 expansion switched to Troika Text meshes with text string + metadata + precomputed bbox; font preloaded once per session (preloadFont({font, characters: 'asciiOnly'})). SDF text is v2 only if outline payload dominates.

7.5 Vertex budget & caps

Entity count alone doesn't bound GPU memory — arc-heavy or text-dense drawings can explode to tens of millions of vertices. Caps enforced at worker time:

  • Entity cap: 250 000 (hard-reject upload above)
  • Vertex cap: 2 000 000 post-tessellation (hard-fail render above)
  • File cap: 100 MB
  • Server parse timeout: 5 min

The vertex cap is the iPad safety valve. Without it the entity count limit is hollow.

7.6 Contract gotchas (documented in proto comments)

  • Handles are always UPPERCASE hex; worker normalizes on write.
  • layer_idx is validated at worker time; entities referencing deleted layers fall back to layer "0".
  • linetype_idx = 0xFFFFFFFF is the ByLayer sentinel.
  • Worker recomputes bounds from entities. DXF header EXTMIN/EXTMAX ignored.
  • Per-drawing file lock prevents concurrent render of the same drawing.

7.7 Known parsing limitations

Two classes of source content cannot be fully rendered without external tooling. Both are documented here so workflow choices upstream of Pinley can compensate.

7.7.1 3DSOLID equipment volumes (ACIS BREP) — RESOLVED 2026-04-29

Status: closed. What follows is preserved as historical context. The fix is documented at the end of this section.

Source: AutoCAD MEP equipment blocks (ACC-1, ACC-3, EVAP-3, MUA-1, KXF-1+, vendor-supplied unit blocks) frequently store the unit body as a 3DSOLID entity. The geometry lives in a binary ACIS payload (Spatial Inc.'s commercial BREP kernel — Faces / Edges / Loops / Shells topology).

Original limitation (before resolution): ezdxf 1.4's high-level acis.api.load() fails to decode SAB-format payloads with ParsingError: expected str token, got (0x14, ...) on transform records (because Body.restore_common doesn't know all SAB sub-record tags) and IndexError on legacy SAT v106 (because parse_header over-eats records). Original behaviour: 3DSOLID entities skipped silently. Estimators saw floating equipment tags without rectangle outlines.

Resolution: parser uses ezdxf's lower-level tokenizers (acis.dbg.dump_sab_as_text for binary, hand-split records for SAT) and scrapes the point and transform records directly — bypassing the broken high-level Body restoration. For axis-aligned bbox the topology graph isn't needed, just the body's vertex coordinates and root transform. See apps/api/parser/acis_bbox.py (SAB path) and acis_bbox_sat.py (SAT path); _emit_3dsolid_bbox in scene_builder.py is the call site that emits a closed-rectangle polyline per 3DSOLID.

Validation: 6 production samples by two independent AI research agents (Claude Opus 4.7 + Codex gpt-5.5). 3792/3792 bbox extractions succeed across 4+ vendor toolchains (AutoCAD MEP AecbDbMvPart, PractiCAD CPrcd*, and unidentified Open AI / Lever House / Netflix shop drafters). 99/99 parser tests pass post-fix. See docs/proposals/2026-04-29-3dsolid-research-result-claude-v2.md for the full research report.

Approximation note: the emitted outline is an axis-aligned bbox, not the true silhouette. For HVAC equipment (mostly rectangular in plan view) this matches the AutoCAD plot within ~5%. Curved or angled bodies underbound slightly. Acceptable per §1.2 (80–90% match target). The Forge fallback (Autodesk Platform Services Model Derivative API, ~$0.5–2 per file) remains available for the rare freeform-NURBS case if reported in production.

Other paths considered and rejected:

Approach Stack Cost Why rejected
Workflow: EXPLODE 3DSOLIDs in AutoCAD before upload Manual estimator step $0 — change in process Solved server-side; no manual step needed
Cloud ACIS render via Forge Autodesk Platform Services ~$0.5–2 per file Kept as Layer 3 fallback for edge cases
Server-side AutoCAD/LT (headless) Windows VM + LT license $1.7k/yr advertised, ~$5–10k/yr realistic (LT lacks API; full AutoCAD + enterprise license required for headless server use per Autodesk EULA) + ops Replaced by server-side Python
Commercial DWG SDK with ACIS ODA Teigha or Bricsys $20k–100k/yr Replaced by server-side Python

Wait-for-ezdxf was not viable: ACIS support is open-source community work with single-maintainer ezdxf — low priority, multi-year horizon. The fix that shipped uses ezdxf's existing low-level APIs (which work) rather than waiting for the high-level API to be patched upstream. Upstream PR to fix ezdxf/acis/sat.py Bug A (parse_header over-eat) and ezdxf/acis/entities.py Bug B (Body slot ordering for ACIS <700) is a future option but no longer load-bearing.

7.7.2 Unbound XREF dependencies

Source: Architectural background, sprinkler network, structural beam labels, etc. live in external DWG files referenced via XREF from the main mechanical DWG. Common in multi-trade construction projects where each subcontractor owns their own drawing.

Limitation: ODA File Converter resolves XREF layer names into the converted DXF (visible as ACS_641 Lexington Ave_Ground floor_BG_Rev.00|A-WALL etc.), but the actual block geometry stays empty (block 'ACS_641...': 0 entities) when the source XREF file isn't bundled with the DWG. Pinley currently accepts a single DWG upload — the supporting XREFs aren't received.

Current behavior: XREF-prefixed layers exist in the DB schema but contribute zero entities to the rendered scene. Visible gap on user side: missing walls/columns/beams as architectural underlay, missing pink sprinkler/conduit network.

Paths to full fidelity (priority down):

Approach Cost
Workflow: XBIND XREFs in AutoCAD before upload (or BIND cmd) $0 — change in process; binds external geometry into source DWG
Multi-file upload with XREF resolution ~3–5 dev-days backend + frontend
Cloud DWG service that resolves XREFs server-side Same SDK costs as 7.7.1

7.7.3 Versioning

When/if either limitation is resolved, bump renderer_version (not scene_schema_version — wire format unchanged). Boot-time resetStaleSchemaDrawings will re-parse existing drawings to recover newly-extractable geometry.

7.8 Version stamps (dual, not redundant)

Two independent version fields, both committed as Go constants (not env/runtime config — rolling deploys would otherwise produce cache ambiguity):

  • scene_schema_version — protobuf shape. Bumps when a field's semantic meaning changes.
  • renderer_version — geometry/text output. Bumps when render algorithms change (e.g., tessellation algorithm update).

Filename: scene_<layoutId>_s<schema>_r<renderer>.pb.gz. Both fields are also inside the proto for runtime verification.

Client behavior:

Client schema vs scene Behavior
schema matches Use scene
Scene newer "App update required" hard-fail (no silent degrade)
Scene older Trigger server re-render with current renderer; show loading

Re-parse triggers (resetStaleSchemaDrawings run on worker boot): drawings with scene_schema_version < current OR renderer_version < current are re-enqueued. Idempotent.

7.9 Stdout manifest format

[
  {"layout_id": 0,         "kind": "scene",    "path": "/tmp/.../scene_0.pb.gz", "sha256": "...", "bytes": 482193, "scene_schema_version": 3, "renderer_version": 7},
  {"layout_id": 0,         "kind": "thumb",    "path": "/tmp/.../thumb_0.png",   "sha256": "...", "bytes": 18234},
  {"layout_id": "metadata","kind": "metadata", "path": "/tmp/.../metadata.json", "sha256": "...", "bytes": 3211}
]

8. Frontend — SceneCanvas

8.1 Architectural principles

SceneCanvas.jsx is the single canvas. Three consolidation rules:

  • Single OrthographicCamera is the source of truth for pan/zoom. Frustum-based. No CSS transform on the canvas container or any ancestor — enforced via a lint rule plus runtime assertion in dev builds. Browser zoom, DPR changes mid-session, and backing-store↔CSS size are handled explicitly.
  • Shared world↔screen matrix service (cameraService.js) exposes the current camera matrix to EntityHitLayer, ZoneOverlay, and AnnotationOverlay. Fixes the coord-sync limitation documented in the 2026-04-11 spec.
  • WebGL context-loss recoverywebglcontextlost and webglcontextrestored handlers re-upload BufferGeometry from the in-memory scene cache. Do not re-fetch scene.pb.gz. Load-bearing on iPad Safari.

8.2 Scene decode (sceneDecoder.js)

  • Fetches scene_<layoutId>_s<schema>_r<renderer>.pb.gz from blob (signed URL).
  • Gunzips (fflate.gunzipSync) → protobuf decodes (@bufbuild/protobuf generated types).
  • Version stamp check → throws SceneVersionError if mismatch.
  • Builds a Flatbush spatial index over entity bboxes in world coords (primitive coords are bounds-local on the wire; add scene.bounds.min_{x,y} on read).
  • Exports entityWorldBbox(entity, dx, dy) for consumers (e.g., EntityMultiSelectTool).

Supported bbox primitives: polyline, circle, arc, hatch (outer loops only), text (rotated box from bbox_w/h).

8.3 Scene geometry build (sceneRenderer.js)

Groups entities by layer into at most one LineSegments mesh + one filled Mesh per layer:

  • polyline → line segments (closed or open)
  • circle / arc → tessellated line segments per zoom
  • hatch solid → ShapeGeometry (outer Shape + hole Shapes) → Mesh with MeshBasicMaterial({color: layerColor, side: DoubleSide, depthTest: false, depthWrite: false}), renderOrder = -1 so fills sit behind lines without occluding them
  • hatch pattern (non-solid) → skipped in v1 (boundary strokes from DXF usually cover)
  • texttroika-three-text Text mesh per entity, positioned at (x, y, 0.01), rotated by rotation_rad, anchored per justify_h/v, renderOrder=1, depthTest:false so text always wins z-fight

Font preload is module-level, awaited by buildSceneGroup:

import { Text, preloadFont } from 'troika-three-text';
const FONT_URL = '/fonts/Inconsolata-Regular.ttf';
export const fontReady = new Promise((resolve) => {
  preloadFont({ font: FONT_URL, characters: 'asciiOnly' }, resolve);
});

8.4 Layout shell (desktop ≥1024 px)

┌─────────────────────────────────────────────────────────────────┐
│ ← 390 Park / 8th Floor Mechanical  [Model ▾]          [···]    │
├────────────┬───────────────────────────────────────┬────────────┤
│ LAYERS     │                                       │ ZONES      │
│ (240 px)   │   SceneCanvas + SVG overlays          │ (300 px)   │
│            │                                       │            │
│  Layer     │   - Zone polygons (status-colored)    │  Summary   │
│  sidebar   │   - Annotations                       │  List      │
│            │   - Entity-pin markers                │  Filters   │
│            │   - Mode toolbar                      │  Detail    │
│            │                                       │  History   │
└────────────┴───────────────────────────────────────┴────────────┘

8.5 Three interaction modes

Switched via floating toolbar (top-right). Single active mode at a time.

  • View (default): pan/zoom, click zone to inspect.
  • Zone: click polygon vertices, double-click / Enter to close.
  • Annotate: sub-tool toolbar with 5 options (Pin, Text, Arrow, Rectangle, Entity Pin). Tools 1-4 are free-form; tool 5 delegates clicks to EntityClickCapture for entity-linked pinning.
  • Multi-Select: rubber-band rect → batch-annotate hit entities.

EntityClickCapture is mounted only when Entity Pin (tool 5) is active OR when re-linking an existing annotation. Otherwise AnnotationTools' own SVG owns clicks — this fix was shipped alongside the ProgressOverlay strokeWidth zoom-scaling fix on 2026-04-23.

8.6 Responsive

  • Desktop (≥1024 px): 3-column as shown.
  • Tablet (768-1023 px): right panel collapses to drawer on zone click.
  • Mobile (<768 px): full-screen canvas; layers + zones as bottom sheets.

8.7 Scene blob caching

  • scene_<layoutId>_s<schema>_r<renderer>.pb.gz per layout
  • 180-day TTL + nightly GC job. Scenes accessed within TTL have last_accessed_at refreshed; idle ones expire and are re-rendered on next open.
  • Thumbnails (300 px PNG per layout) follow the same TTL.

9. Layer Classification Rules Engine

Client requirement (2026-04-22): new firm prefixes must be supportable within 1-2 days. Hardcoding is out.

9.1 Precedence

Most-specific-wins, then sort_order within scope:

project override (project_id=X)  →  firm (firm_id=F, project_id=null)  →  global (firm_id=null, project_id=null)

Within a scope level: first match by sort_order wins. Layer name matched against pattern_regex via Python re.search (not re.match — keyword patterns must match anywhere in the name, not only at start).

No match in any scope → discipline_code="MISC", classification_source=FALLBACK.

9.2 Seeded ruleset

Two seeders, both registered in apps/api/seeder/seeder.go:

  • SeedLayerClassificationRules — v1 baseline, 9 bare AIA letter-prefix rules (^H-.*, ^P-.*, etc.) + firm-prefixed variants.
  • SeedLayerClassificationRulesHVAC — v2 HVAC-dense pass, 25 rules targeting real-world GC patterns. Split as its own seeder so it runs on existing DBs without re-running v1 (the outer runner gates on seeder_name marker).

Rule categories in the HVAC pass:

sort_order Pattern Discipline Segment
10 (?i)^sm[-_]?sa(?:lp|mp|hp)?(?:[-_]|$|[a-z]) HVAC SUPPLY
11 (?i)^sm[-_]?ra(?:[-_]|$|[a-z]) HVAC RETURN
12 (?i)^sm[-_]?ea(?:[-_]|$|[a-z]) HVAC EXHAUST
13 (?i)^sm[-_]?oa(?:[-_]|$|[a-z]) HVAC OUTSIDE
14 (?i)^sm[-_]?ta(?:[-_]|$|[a-z]) HVAC TRANSFER
15-18 supply/return/exhaust/outdoor ... (air|duct|diff|grill|grll) HVAC SUPPLY/RETURN/EXHAUST/OUTSIDE
20 (?i)^sm[-_] HVAC
21 (?i)(?:^|[-_|\s])ducts?(?:[-_]|$|\s) HVAC
22 (?i)(?:^|[-_|\s])(hvac|mech(?:anical)?|mec) HVAC
23 (?i)(?:^|[-_|\s])(diffus|grill|grll|vav|ahu|rtu|fcu|chill|plenum|cfm) HVAC
24 (?i)^m[-_] HVAC
25 (?i)^[a-z]{2,5}[-_]m[-_] HVAC
30-31 plumbing keywords (sanitary/waste/domestic/hydronic/sewer/drain/sprinkler) PLUMBING
40 electrical keywords (conduit/cndt/lighting/panel/cable/tray) ELEC
55 structural keywords (beam/column/steel/joist/slab/framing/rebar) STRUCT
65 arch keywords (wall/door/window/ceiling/glaz/partition/finish) ARCH
70-74 \|a[-_], \|s[-_], \|m[-_], \|e[-_], \|p[-_] (XREF subname disambig) ARCH/STRUCT/HVAC/ELEC/PLUMBING
50 ^[A-Z]{2,}-H/P/E/S-.* (firm prefix) HVAC/PLUMBING/ELEC/STRUCT
100 ^H/P/E/S/A-.* (bare AIA prefix) HVAC/PLUMBING/ELEC/STRUCT/ARCH

Observed coverage on Battery Park MECH (128 layers): HVAC 40% / ARCH 27% / MISC 27% / ELEC 4% / STRUCT 2%. Segment groups populated on 32 layers (12 SUPPLY / 6 RETURN / 6 EXHAUST / 6 TRANSFER / 2 OUTSIDE).

9.3 Versioning (append-only)

Updating a rule inserts a new row with incremented version and sets the old row's superseded_by_id. Drawings record matched_rule_id + matched_rule_version per classified layer. Historical classifications remain auditable after rule edits.

9.4 Reclassification on rule change

Manual batch only. Admin action "Re-apply rules" triggers a classification-only job — re-runs the classifier over existing drawings' layer metadata. Does not regenerate scene.pb.gz (handles don't change; no render needed). Auto-on-open was considered and rejected: users opening an old drawing should not see layer groupings silently drift.

9.5 Human override (v2 UI, v1 schema-ready)

When the v2 UI ships:

  • User flags a layer as misclassified → creates a draft rule with classification_source='feedback'.
  • Admin reviews, approves, promotes to active.

v1 has no UI for this, but the schema column, DrawingClassificationSource proto enum, and API surface are in place so v2 work is additive.

9.6 Rule loading at parse time

drawing_parse_worker.go :: loadActiveClassificationRules(ctx, drawing) queries:

SELECT ... FROM layer_classification_rules
WHERE enabled = true
  AND superseded_by_id IS NULL
  AND (firm_id IS NULL OR firm_id = ?)
  AND (project_id IS NULL OR project_id = ?)
ORDER BY sort_order ASC;

Passed to drawing_worker.py via stdin JSON as active_classification_rules. Python classifier.classify_layer(name, firm_id, project_id, rules) picks the winning rule per _scope_key (most-specific scope, then lowest sort_order).


10. Zones — progress tracking

See §4.3 (DrawingZone), §4.4 (DrawingZoneStatusEvent), §5.4 (status update contract). Interaction-level details:

10.1 Zone drawing (PM on desktop)

  1. Toolbar → Zone mode.
  2. Click vertices, double-click / Enter to close.
  3. Modal: name, description, optional layer tags, initial status.
  4. Save → polygon renders in canvas + appears in zones panel.

Polygon coords stored in layout units (paperspace inches for paper-space layouts, DXF world coords for Model). Canonical shape [{"x":…,"y":…}, …]; read path also accepts legacy [[x,y],…].

10.2 Zone interaction (field user on tablet)

  1. Taps zone on canvas.
  2. Bottom sheet slides up:
  3. Zone name + current status badge
  4. 5 big status buttons (big tap targets)
  5. Optional note field
  6. Last 3 history entries
  7. Taps new status → optimistic UI update → API call.
  8. On 409 conflict: toast "Someone updated this zone" + auto-refetch + re-prompt.

10.3 Zone status colors (ProgressOverlay)

NOT_STARTED gray outline no fill · IN_PROGRESS blue 20% fill · COMPLETE green 20% fill · BLOCKED red 20% fill red border · NEEDS_REVIEW amber 20% fill amber border.

Zoom-invariant strokes: ProgressOverlay SVG uses a viewBox in world coords. Raw pixel values for strokeWidth / fontSize balloon at high zoom. Strokes and text scale by viewBox.width * ratio so they stay ~constant in screen pixels at any zoom (strokeW = vbWidth * 0.0015, fontSize = vbWidth * 0.012).

10.4 Offline / flaky-connectivity handling (v1)

  • Optimistic UI renders status change immediately; RPC fires in background.
  • Pending badge on zone polygon (pulse/spinner) until RPC confirms.
  • In-memory retry queue: failed requests (network / 5xx) retry with exponential backoff, up to 5 attempts (~30s total). client_request_id prevents duplicate writes.
  • Connection-lost banner: when 3+ consecutive RPCs fail, sticky banner "Connection lost — 3 changes pending". Clears on first success.
  • Permanent failure: after retry limit, revert optimistic change, show "Update failed — tap to retry" toast.
  • Page reload drops queue (v1 limitation — pending updates are in-memory). IndexedDB persistence is v2.

Explicitly NOT offline-first. Online app with graceful degradation during brief connectivity gaps.


11. Annotations

Two parallel concepts:

  • Entity-linked — attached to a specific scene entity (entity_id = instance_id). Click a duct → modal with entity metadata pre-filled. Persists across revisions via handle-exact match; fuzzy match is v2.
  • Free-form — attached to a drawing+layout at a world-coord location. Pin / Text / Arrow / Rectangle.

11.1 Types & geometry shapes

Stored as JSON string in geometry column.

Type Geometry
PIN (1) {x, y} — single point in layout units
TEXT (2) {x, y} — anchor + label in label
ARROW (3) {start:{x,y}, end:{x,y}}
RECTANGLE (4) {x, y, w, h} (top-left + size)
Entity pin {kind:'entity_pin', x, y, entity_instance_id, entity_handle, entity_layer, entity_type, entity_source} — stored as PIN type with kind discriminator

11.2 Entity-linked annotation flow

  1. User selects "Entity Pin" in the Annotate sub-toolbar.
  2. EntityClickCapture overlay captures click → DrawingViewerPage.handleEntityClick(screenPt):
  3. Converts screen → world
  4. Zoom-aware tolerance: tolerance = viewBox.width / 1000 * 6 (~6 screen px at any zoom) — not a fixed world-unit value.
  5. sceneCanvasRef.hitTest(world, tolerance) queries Flatbush → entity indices.
  6. Sort by bbox area ascending (per spec §"Entity proximity hit-test"): smallest bbox containing the click wins, with hatch de-prioritization (hatch fills span large regions; prefer non-hatch hits, fall back to hatch only if no stroke hits).
  7. First visible non-hatch → adaptSceneEntity builds {instanceId, handle, layer, type, source}.
  8. EntityAnnotationModal opens with entity metadata + textarea for note. Saves as PIN annotation with geometry.kind='entity_pin'.

11.3 Free-form annotation flow

AnnotationTools SVG captures clicks when tools 1-4 (Pin/Text/Arrow/Rectangle) are active:

  • Pin: one click → AnnotationLabelModal (optional label) → PIN annotation
  • Text: one click → modal with required label → TEXT annotation
  • Arrow: two clicks (start, end) → no label modal → ARROW annotation
  • Rectangle: mousedown + mouseup (drag) → modal with optional label → RECTANGLE annotation

When Entity Pin (tool 5) is active, AnnotationTools SVG goes pointer-events-none and EntityClickCapture handles clicks.

V1: handle-exact match only. Parse worker emits a mapping table (layout_id, handle) → instance_id during re-parse; post-parse hook rewrites drawing_annotation.entity_id using this map. Unmatched rows get status='needs_relink'.

V2 (designed, not shipped): fuzzy fallback — find entity in the new revision with same layer + same dxftype + bbox center within 2 paperspace inches of stored anchor. Multiple candidates → pick closest. None → orphan the annotation with a reassign UX.

11.5 No browser dialogs

All confirms / prompts go through ConfirmModal and AnnotationLabelModal (in features/drawing-viewer/components/). Neither window.prompt nor window.confirm is used anywhere in the feature.


12. Paper-space viewport expansion

Published PDFs show content through paper-space VIEWPORT entities that "window" into model space at specific scales with specific frozen-layer overrides. Rendering paper-space entities directly (without resolving viewports) produces empty layouts with only a title block.

The 2026-04-23 spec introduced viewport_expansion.py, a shared module used by both render_layout.py and scene_builder.py on paper-space layouts.

12.1 Algorithm

  1. Flatten model-space once into a WCS cache (shared across all viewports):
  2. For each MSP entity, expand virtual_entities() to resolve INSERTs and blocks to primitives
  3. Resolve effective layer: layer "0" inside nested blocks inherits from the outermost INSERT's layer
  4. Enumerate paper-space-native entities (title block, borders, notes). Skip VIEWPORT entities themselves.
  5. For each content viewport (skip id=1, filter status>0, skip non-top views, skip extended clipping path):
  6. Derive forward transform MS→PS via vp.get_transformation_matrix() (fallback: compute manually from view_center_point / center / view_height / width / height / view_twist_angle)
  7. Compute viewport's MS rect and PS rect
  8. frozen = set(vp.frozen_layers) — honored at expansion time (VP_FREEZE)
  9. For each MSP entity: cull by MS bbox intersect with viewport's MS window; if it survives, yield a PaperspaceEntityInstance(source_entity, effective_layer, transform, clip_rect, provenance='ms:<vp.handle>')
  10. Render-order rule: paper-space native entities emitted after viewport-expanded entities (array order = z). Title blocks and annotations sit on top of viewport content, matching CAD plot behavior.

12.2 Non-goals (logged as warnings, skipped)

  • Non-rectangular viewport clips — fall back to rectangular bbox crop
  • Non-top view_direction_vector — skip viewport
  • External xrefs (separate DXF files referenced by the drawing) — skip block, one warning per xref target
  • Full MTEXT rich layout (inline codes, fractions, color runs) — plain_text() only
  • Per-viewport layer visibility toggle in UI (VP_FREEZE IS honored at expansion; UI override is v2)
  • Stable cross-sheet entity identity (same duct across Sheet M1 + M2) — each layout is an independent scene with its own instance_id sequence

12.3 Text emission (2026-04-23)

  • TEXTTextBlock with x, y, height, rotation_rad, text, justify_h, justify_v, width_factor, oblique, bbox_w, bbox_h
  • MTEXTTextBlock with text = plain_text(), rotation already in radians, multi-line bbox from max(len(line)) × char-width × line-count
  • DIMENSION → decomposed via virtual_entities() into component lines + arcs + text; each child emitted through the standard dispatch (one level of recursion)
  • LEADER / MLEADER / MULTILEADER / ACAD_PROXY_ENTITY → in _EXPLODABLE_TYPES set, recursively exploded via virtual_entities() up to depth cap

13. Bounds & Zoom Extents

13.1 Outlier trim algorithm

Classic CAD files contain stray entities at extreme coords — hatch seed points, construction markers, user accidents at (1e6, 1e6). Pure min/max over every vertex zooms the main cluster to sub-pixel.

Algorithm (scene_builder.py):

  1. Compute per-entity bboxes in the first pass (cache).
  2. full_bounds = union of all entity bboxes.
  3. trimmed_bounds = bounds of entities whose center falls within the 2%–98% percentile of entity-center coords (separately on X and Y).
  4. Decide:
    full_span = max(full_bounds.max_x - full_bounds.min_x, full_bounds.max_y - full_bounds.min_y)
    trim_span = max(trimmed_bounds.max_x - trimmed_bounds.min_x, trimmed_bounds.max_y - trimmed_bounds.min_y)
    bounds = trimmed_bounds if full_span / max(trim_span, 1e-6) > 10.0 else full_bounds
    
    Only pathological drawings get trimmed; normal drawings are unchanged.
  5. Emit both bounds (used for fit-on-load) and full_bounds (used for Zoom Extents).

13.2 Frontend affordance

  • cameraService.fitToBounds(scene.bounds) — default fit-on-load (may be trimmed).
  • cameraService.fitToFullBounds(scene.full_bounds) — "Zoom Extents" toolbar button.
  • "Zoom Extents" button is only visible when full_bounds differs meaningfully from bounds (trim happened).

13.3 Coordinate-precision note under trim

bounds-local float32 subtracts the trimmed origin. Outlier entities can end up with coords larger than float32 safe-integer (±16M) if truly extreme. Decision: outlier entities still emit with their true dx,dy subtraction even when centered far from trimmed origin. Losing precision on entities the user explicitly opted into viewing is acceptable. Test asserts local-coord range within float32 safe-int for the typical drawing.


14. iPad constraints

First-class target. Every other section honors these:

Constraint Value
MAX_TEXTURE_SIZE 4096 (vs 8192–16384 desktop)
Memory ceiling ~1.5–2 GB / tab; Safari kills aggressively under pressure
WebGL context loss Common on backgrounding; recovery mandatory
Input Pinch-zoom, two-finger pan, tap, long-press — every interaction supports both mouse and touch
Upload caps 100 MB file, 250 K entities, 2 M vertices, 5 min server timeout
Library Three.js in v1 (continuity). Benchmark-gated fallback to PixiJS in v2 only if Phase 0 fails worst-case thresholds.

15. DXF handle stability & re-upload

Handle stability is load-bearing: every entity-linked annotation has a hard FK to the scene's instance_id, which derives from the DXF handle. Three mitigations:

15.1 Lock converter per drawing

Drawing.converter_used = oda | libredwg. Every re-render uses the same converter. If unavailable at re-render time, fail loudly (CONVERTER_UNAVAILABLE in UI). Never silently switch — ODA and LibreDWG emit different handle sequences, and a silent swap breaks every entity-linked annotation.

15.2 Persist the converted .dxf

For DWG inputs, the converted intermediate .dxf is stored at drawings/{drawing_id}/rendered.dxf. Re-renders read the stored DXF, never reconvert from DWG. Handles are guaranteed stable across renderer_version bumps.

15.3 Re-upload = new Drawing ID

Replacing a file creates Drawing#N+1 with parent_drawing_id FK, incremented version_number, and is_archived flag on the old row. Annotations stay attached to the version they were authored against.

Replace UI presents three options:

  • Keep both — new drawing active, old read-only with annotations intact.
  • Archive old — new drawing active, old hidden from list.
  • Migrate annotations — best-effort geometric match (handle + proximity) from old → new. Unmatched annotations → review queue (v2 UX).

16. Reliability, idempotency, retry

16.1 State machine

uploaded → converting → parsing → rendering → complete
                                           ↘ failed

Any transition failure → failed with structured error_code + error_detail. No partial complete state.

16.2 Idempotency

  • Uploads carry an idempotency_key (client-generated UUID). Duplicate uploads with the same key are deduped server-side — one Drawing row, one parse job.
  • Separate from the file_hash dedupe lock (60s window) that catches concurrent uploads of the same content.
  • Status updates use client_request_id — duplicate with same payload returns the existing event.

16.3 Retry + DLQ

  • One auto-retry after 60s on transient failure (OOM, timeout).
  • Second failure → job moved to dead-letter queue, marked failed, surfaced in UI with manual retry action (RetryDrawingParse RPC).
  • Prevents masking real bugs behind infinite retry.

16.4 Observability

Server-side, per ingest, structured fields: drawing_id, file_size_bytes, converter_used, duration_ms_total / convert / parse / render_total, entities_count, vertices_count, skipped_entities_count (breakdown by type), layouts_count, output_size_total, trace_id.

Client-side: scene_load_ms, scene_decode_ms, first_paint_ms, zoom_fps_p50, zoom_fps_p95, webgl_context_loss_count.

SLO alerts: ingest_duration_p95 > 5 min → page oncall.


17. Data lifecycle, quotas, authorization

17.1 Retention

Artifact Retention
Original uploaded file Forever (legal/audit)
Converted .dxf (DWG inputs) Forever — pinned to Drawing ID per §15.2
scene_<layoutId>.pb.gz 180 days TTL + GC job. Regenerable from stored DXF.
thumb_<layoutId>.png 180 days TTL + GC job. Regenerable.
Audit log Forever

GC job runs nightly. Scenes accessed within TTL have last_accessed_at refreshed; idle scenes expire and re-render on next open.

17.2 Quotas (per firm, v1 defaults)

  • Uploads: 100 per 24-h rolling window
  • Concurrent ingests: 5 in flight
  • Total stored drawings: no cap in v1 (revisit when blob-storage cost signal arrives)

Admin UI to override is v2.

17.3 Authorization matrix

Action Roles
Upload drawing PM, Admin, Executive
View drawing All internal roles + external portal (project-scoped)
Trigger re-render PM, Admin
Download original / converted .dxf Admin, Executive
Admin rules engine Admin, Executive
Reclassify all drawings Admin

17.4 Permissions (seeded via seedModulePermissions())

Permission Admin PM Field Estimator
drawing.view
drawing.manage (upload/delete)
drawing.zone.manage
drawing.zone.update_status
drawing.annotate

Estimator and Viewer roles can't update status (not their job). Field users can't delete zones but can update status and annotate.


18. Error handling matrix

18.1 Upload

Error Response code
File size > 100 MB DRAWING_TOO_LARGE
Entity count > 250 K post-parse DRAWING_ENTITY_LIMIT_EXCEEDED
Vertex count > 2 M post-tessellation DRAWING_VERTEX_LIMIT_EXCEEDED
DWG conversion fails (both converters) DRAWING_UNSUPPORTED_FORMAT
Parse timeout > 5 min DRAWING_PARSE_TIMEOUT
Firm quota exceeded DRAWING_QUOTA_EXCEEDED

18.2 Parse

  • Malformed DXF → DRAWING_PARSE_FAILED, whole ingest fails
  • Unknown entity types → skip + persistent UI banner: "N entities not rendered (type: X)". Log warning per-entity type. Aggregated server-side for backfill priorities.
  • NaN / Inf coordinates → skip entity, log warning
  • Duplicate handles in legacy DXF → keep first, strip rest, log warning (subsequent duplicates lose annotation attachment — graceful)
  • INSERT to undefined block → skip, warn

18.3 Render

  • Missing glyph → DejaVu fallback, log warning
  • Self-intersecting hatch boundary → Shapely repair attempt, else LineSegments boundary only
  • Python OOM → whole worker fails atomically

18.4 Client-side

  • scene.pb.gz 404 → re-trigger server render, show loading
  • Corrupt binary → React error boundary + "Contact support" + telemetry event
  • scene_schema_version > client → "App update required" hard-fail
  • scene_schema_version < client → trigger re-render with current renderer, show loading
  • WebGL context loss → re-upload BufferGeometry from in-memory scene cache (no re-fetch)
  • 409 on status update → toast + auto-refetch + re-prompt
  • Parse failure → red badge + "View error" button → error details + Retry
  • Upload failure → toast with retry, keeps form state

19. Edge cases

Case Handling
Empty drawing (no entities) Empty entities[], UI shows "no drawable content" overlay
Model-only drawing (no paper layouts) 1 layout
100+ layouts Render first 20 eagerly, rest lazy-render on first open. UI marks unready layouts.
Concurrent ingest of identical file_hash Deduped by 60s lock, both requests receive same Drawing ID
Worker killed mid-run /tmp cleanup cron (daily) + job re-enqueue (1 auto-retry)
scene_schema_version bump All existing scenes lazy re-render on next open (preserves zones/annotations)
renderer_version bump Existing scenes still load; background batch re-render in quiet hours
Layer-panel interactions (visibility, isolate, color override) Client-side only; never round-trip. Color overrides persisted in per-user per-drawing preferences.
VP view_direction_vector != (0,0,1) Skip VP, emit warning in warnings[]
VP extended clipping path Skip VP, emit warning
VP get_transformation_matrix() raises Skip VP, emit warning
Entity with zero-size bbox Skip instance
MTEXT with empty text after plain_text() Skip instance
Layer "0" inside nested block Effective layer = outermost INSERT's layer
Xref entity Skip, emit warning (one per xref target)
DIMENSION nested inside block One level of virtual-entities recursion; deeper skipped with warning
Circular virtual_entities reference Depth limit 8; warning + skip at limit
VP view_height <= 0 / height <= 0 Skip VP
Font load failure Scene still mounts; text entities silently skipped; console.warn once
Clip rect cuts off text mid-string Troika clipRect crops visually; no character-level clipping needed

20. Testing strategy

20.1 Unit (pytest / go test / vitest, >80% on parser / worker core)

Round-trip coordinate math, primitive→proto snapshots, manifest integrity, rules engine matching, camera world↔screen math, classification precedence, version stamp parsing.

20.2 Integration (CI per PR)

Fixtures in apps/api/parser/testdata/fixtures/:

  • minimal-square.dxf (5 KB) — smoke
  • battery-park.dxf (22 MB) — real-world medium
  • heavy-mep.dwg — stress
  • pattern-hatch.dxf — hatch tessellation
  • exotic-fonts.dxf — glyph fallback
  • corrupt-header.dxf — parse error path
  • limit-boundary.dxf (99 MB, 249 K entities) — at-limit success
  • over-limit.dxf (101 MB) — rejection
  • vertex-bomb.dxf — arc-heavy, triggers vertex cap
  • libredwg-only.dwg — converter fallback path

Invariants per fixture: exit code 0 (or expected error), valid manifest, correct version stamps, entity count in manifest matches proto, all handles non-empty, no duplicate handles.

20.3 Visual regression

Playwright on headless Chrome (Linux CI). Pixel-diff per-layout thumbnails against baseline. Baselines in Git LFS.

20.4 Performance benchmarks

Desktop (Chrome, 2020+ MacBook):

  • Scene decode < 500 ms on 22 MB input
  • Zoom / pan ≥ 60 FPS

iPad Safari (M1+, real device):

  • Scene decode < 1.5 s
  • Pinch-zoom ≥ 30 FPS
  • Peak memory < 500 MB for medium fixture, < 1.2 GB for worst-case

Phase 0 gate (mandatory before Phase 1 ships): benchmark 3 representative files on real iPad hardware. Fail thresholds:

  • Peak memory > 1.2 GB
  • Pan/zoom < 20 FPS sustained
  • Initial render > 8 s

Any failure → iPad deferred to v1.5, v1.0 ships desktop-only with iPad flag forced off.

20.5 Rules engine tests

  • Priority resolution (global vs firm vs project override vs sort_order)
  • Disable behavior
  • Versioning append-only invariant
  • Fuzzer: 10 K random layer names, zero false-positive matches against hardcoded negative set
  • Reclassification job idempotence

20.6 DWG converter tests

  • ODA happy path
  • Simulated ODA failure → LibreDWG fallback produces valid DXF (warning logged)
  • Both fail → DRAWING_UNSUPPORTED_FORMAT
  • Re-render uses recorded converter; hard-fails if unavailable

20.7 E2E (Playwright)

  • drawing-viewer-paperspace-zone.spec.js — polygon format hotfix regression
  • drawing-viewer-paperspace-expanded.spec.js — viewport content, text count, entity-pin click
  • drawing-viewer-zoom-extents.spec.js — pathological outlier drawing, Zoom Extents button

21. Rollout phases & no-go gate

Delivery is phased behind DRAWING_VIEWER_V2_ENABLED (backend) and VITE_DRAWING_VIEWER_V2 (frontend).

21.1 Phases

Phase 0 — Foundation + iPad benchmark gate. Docker: add ODA + LibreDWG + libfreetype + font fallbacks. Proto: commit drawing_scene.proto. DB migrations: layer_classification_rule, new Drawing columns (converter_used, version_number, parent_drawing_id, is_archived). Seed classification rules replicating hardcoded prefix logic. Benchmark gate blocks Phase 1 — fail → v1.0 ships desktop-only.

Phase 1 — Parallel pipeline. drawing_worker.py behind backend flag (default off). Shadow-mode admin UI renders v2 preview alongside v1 for side-by-side review. Parity assertions on classification vs hardcoded path.

Phase 2 — Frontend rewrite. SceneCanvas.jsx behind frontend flag (default off). ThreeSheetCanvas stays available. Internal dogfood on real customer drawings.

Phase 3 — Dual-render validation. No-go gate must pass. Pixel-diff v1 vs v2. Entity-count parity. Performance benchmarks on desktop + iPad.

Phase 4 — User rollout. All clients at once (explicit user choice). Requires Phase 3 rock-solid. Backfill via off-hours re-parse.

Phase 5 — Deprecation. 30 days post-flip. Remove v1 render code (ThreeSheetCanvas, SvgSheetCanvas, DwgSheetCanvas), SVG helper modules. Hardcoded prefix classification removed once rules engine is stable.

21.2 No-go gate (all 8 required before Phase 4 flip)

  1. Visual regression = 0 on snapshot fixtures (binary + schema-only)
  2. Entity-count parity v1 vs v2 = 100% on all test fixtures
  3. Desktop scene decode < 1 s, zoom/pan ≥ 60 FPS
  4. iPad heavy-fixture decode < 3 s, pinch-zoom ≥ 30 FPS, peak memory < 1 GB
  5. Shadow-mode run over all existing prod drawings: 100% ingest success via v2
  6. Rules-engine classification parity with hardcoded logic: 100% on seed data
  7. ODA + LibreDWG fallback path exercised on ≥5 real DWG files, handles stable across re-render
  8. WebGL context-loss recovery tested on iPad via forced memory pressure, scene fully restored

21.3 Unvalidated assumptions (Phase 0 validation list)

  1. float32 + bounds-local precision at max zoom on real drawings. Mitigation if fails: fall back to float64 (payload doubles).
  2. scene.pb.gz decode + upload fits iPad Safari M1 memory / time budget at vertex cap. Mitigation: progressive loading by layer, or PixiJS evaluation.
  3. ODA and LibreDWG produce equivalent semantic output for handles / layers / text. Validation: diff manifest across 10 real DWG files. Mitigation: lock ODA-only.
  4. Tessellation finishes < 5 min at cap. Mitigation: lower vertex cap or partial-fail fast path (render what parsed, flag the rest).
  5. Atomic manifest + blob move correctness under crash / retry / concurrent workers. Mitigation: explicit two-phase commit, idempotency via SHA256 + path, integration test for kill-mid-move.

22. Success criteria

  • A field foreman can open the viewer on an iPad, find their zone, and mark it complete in under 10 seconds.
  • A PM can see "12 zones: 4 complete (33%), 5 in progress, 2 blocked" for any drawing at a glance.
  • Every status change is traceable back to who / when / why.
  • Pinley stops emailing marked-up PDFs for progress tracking (measure: usage metric over 30 days).

23. Outstanding / deferred items

  • Legal review of ODA EULA for redistribution in Docker image (pre-GA blocker; if restricted, LibreDWG becomes primary with handle-stability risk mitigated by §15.1 lock).
  • Ivan's heavy test files for Phase 0 benchmark (not yet in hand as of 2026-04-22).
  • Font subsetting — outline/Troika glyph payloads may balloon on text-dense MEP sheets.
  • Annotation migration algorithm on re-upload — "best-effort geometric match" vague; v2 defines exact algorithm + review-queue UX.
  • XREF resolution — explicit non-goal; many GC drawings reference external .dwg files (arch background, fire, elec). Options (requires product decision):
  • A. UX change: preflight modal instructs users to XREF → Bind before export, or accept multi-file upload of the referenced files alongside the main DWG. Cheap.
  • B. Multi-file sheet-set upload: parser resolves XREFs via ezdxf's loader, composes all trades into one scene. Substantial.
  • C. Accept the limitation; single-drawing views show MECH only.

24. Measurement tools

Ported from AI-prototype/docs/content/workflows/06-takeoff.md §"Measurement Tools". In-scope for Pinley because field foremen and PMs need to measure distances, count equipment, and estimate filled areas during inspection and progress reporting — even though we don't do takeoff pricing.

The viewer exposes a set of measurement tools distinct from annotation tools. Annotations are labels (pin, text, arrow, rectangle with a name); measurements are values (a number in feet or a count) tied to geometry on the drawing. Both persist on the DrawingAnnotation table in v1 with type discriminating, but measurements additionally store a numeric result in the geometry JSON blob.

24.1 Tools

Tool Shortcut Interaction Result
Select Esc Pan / zoom / click to inspect existing items
Linear L Click 2 points Distance in feet (or current unit)
Count C Single-click counter for equipment / diffusers / devices Running integer count per category
Area A Click polygon points, double-click / Enter to close Square feet inside polygon
Rectangle D Click two opposing corners Square feet of the bounded rect
Zone Z Polygon draw (§10.1) Progress-tracked region
Entity Pin E Click an entity Linked annotation (§11.2)

Shortcuts are single-key while no input has focus. Esc always returns to Select mode. The currently active tool is highlighted in the floating toolbar and reflected in the cursor.

24.2 Geometry JSON for measurements

  • Linear: { kind: "linear", start: {x,y}, end: {x,y}, value_ft: 42.5 }
  • Count: { kind: "count", points: [{x,y}, ...], category: "VAV-box" } — category is user-typed on first point or picked from a previous count
  • Area: { kind: "area", polygon: [{x,y}, ...], value_sqft: 640.2 }
  • Rectangle: { kind: "rectangle", x, y, w, h, value_sqft: 320 } — reuses existing Rectangle annotation shape with an added derived value

Values are recomputed server-side on save from the raw geometry + current drawing scale, so the number is always in lockstep with scale changes.

24.3 Running totals panel

Right sidebar (MeasurementsPanel.jsx, v2) shows a grouped rollup:

Linear:      12 measurements, 845 LF total
Count — VAV:  47 units
Count — Diffusers: 124 units
Area:         3 polygons, 2,840 SF total

Hiding categories toggles their visibility on the canvas.

24.4 Non-goals for Pinley

  • No auto-fitting generation. Counts stay manual; we are not computing elbows/tees/weights. That belongs to takeoff/estimating tools, not progress tracking.
  • No material pricing or labor rate lookup. Measurements are descriptive, not cost-bearing.
  • No SMACNA / MSS SP-69 weight tables. See §30 for the full exclusion list.

25. Scale calibration

Ported from AI-prototype/docs/content/workflows/06-takeoff.md §"Scale Calibration". Required because every measurement (§24) needs a pixel-to-real-world conversion; PDFs rarely carry reliable metadata for this.

25.1 Manual 2-point calibration (MVP)

  1. User clicks Calibrate scale from the toolbar.
  2. Clicks 2 points on a known dimension (door width, column spacing, a dimension-line endpoint).
  3. Enters the real-world distance and unit (feet / inches / mm) in a modal.
  4. System computes scale = worldDistance / pixelDistance and persists on the layout: DrawingLayout.scale_value + DrawingLayout.scale_unit.
  5. Subsequent measurements multiply pixel-space results by scale.

25.2 Preset architectural scales

One-click presets in the calibration modal, for when the drawing has a known printing scale:

  • 1/32" = 1'-0"
  • 1/16" = 1'-0"
  • 1/8" = 1'-0"
  • 3/16" = 1'-0"
  • 1/4" = 1'-0"
  • 3/8" = 1'-0"
  • 1/2" = 1'-0"
  • 3/4" = 1'-0"
  • 1" = 1'-0"
  • NTS (not to scale — disables measurement tools on the layout, shows a warning banner)

25.3 Phase 2: Auto-detection

Backend parses title-block text and scale-bar notation from the rendered scene's TextBlocks:

  • Regex for common scale notations (1/4" = 1'-0", SCALE: 1/4"=1', 1:100, etc.) in text entities within the paper-space title-block region (bottom-right quadrant of the layout bounds, heuristic).
  • If a confident match found, pre-fill the calibration on first open with a "Auto-detected — tap to verify" badge.
  • User must confirm before calibration locks in. Auto-detection is advisory, not authoritative.

This eliminates the largest single source of measurement error — bad calibration accounts for ~34% of manual errors per the AI-prototype reference data.

25.4 Data model additions

Extend DrawingLayout:

ScaleValue *float64   `json:"scale_value"`          // e.g. 0.25 for 1/4"=1'
ScaleUnit  *string    `json:"scale_unit"`           // "ft"|"in"|"mm"|"NTS"
ScaleSource *string   `json:"scale_source"`         // "manual"|"preset"|"auto"
ScaleCalibratedBy *uint32 `json:"scale_calibrated_by"` // FK User
ScaleCalibratedAt *time.Time `json:"scale_calibrated_at"`

GetDrawingLayouts includes these so the viewer can conditionally enable / disable measurement tools.


26. Sheet list + per-sheet completion tracking

Ported from AI-prototype/docs/content/workflows/06-takeoff.md §"Drawing Organization" + §"Layout". Pinley doesn't do takeoff but does need per-sheet progress on large multi-sheet sets (a 60-sheet MEP submission is common).

26.1 Left-panel sheet list

The existing LayerPanel coexists with a new SheetListPanel.jsx in the left sidebar. On drawings with >1 paper-space layout, the sheet list is default-open:

[ Filter: ⯆ Mechanical | Plumbing | Electrical | All ]
[ 🔍 Search ]
━━━━━━━━━━━━━━━━━━━━━━━━━━
● M-101  1st Floor Mech     ◐ partial
● M-102  2nd Floor Mech     ◉ complete
◯ M-103  3rd Floor Mech     (not started)
◯ M-104  4th Floor Mech
⚠ M-105  Roof Mech          ⚠ pre-addendum — verify
━━━━━━━━━━━━━━━━━━━━━━━━━━

Clicking a sheet switches the active layout (existing behavior). The completion badge and filter are new.

26.2 Completion states

A sheet is:

  • Not started — zero zones, zero annotations, zero measurements on the layout.
  • Partial — at least one zone / annotation / measurement exists, but at least one zone on the layout is not in COMPLETE status.
  • Complete — at least one zone on the layout exists and all zones on the layout are COMPLETE.
  • Pre-addendum — verify — a newer sheet revision exists (§27) and at least one item on this layout predates the revision.

Computed on-demand via a single aggregate query per GetDrawingLayouts call (or a layout.completion_state field denormalized on save; denormalized is recommended for large sheet sets).

26.3 Discipline filter

The dropdown filters sheets by their inferred discipline — derived from the sheet name prefix (M-, P-, E-, A-, S-, FP-) or from a manual DrawingLayout.discipline field populated on first view by the PM.

Sheet-name prefix + substring search over layout name. No fuzzy matching.

26.5 API additions

DrawingLayout proto gains:

message DrawingLayout {
  // ... existing ...
  string completion_state = 20;    // "not_started" | "partial" | "complete" | "pre_addendum"
  string discipline       = 21;    // "M" | "P" | "E" | "A" | "S" | "FP" | "MISC"
  int32 zones_total       = 22;
  int32 zones_complete    = 23;
}

Recomputed on every status change that mutates a zone's current_status.


27. Addendum drawing comparison

Ported from AI-prototype/docs/content/workflows/06-takeoff.md §"Addendum Drawing Comparison" + §"Layer 1/2/3". Pinley's domain: when a GC sends a revised drawing mid-project, field progress must not silently carry forward on stale geometry.

Three-layer approach, phased.

27.1 Layer 1 — File-level detection (MVP)

  • On re-upload, the new Drawing (§15.3) is linked to its parent via parent_drawing_id.
  • System diffs the layout list between parent and child by layout_name. Emits:
  • New sheets — layout names in child but not parent.
  • Replaced sheets — layout names in both; child wins.
  • Removed sheets — layout names in parent but not child.
  • For every replaced sheet, mark all existing zones + annotations + measurements on that sheet with status pre_addendum. They stay visible with a badge and a tooltip: "Sheet M-103 Rev 2 replaces Rev 1. 6 measurements totaling 2,800 LBS of supply duct may need re-measurement."
  • Old items persist for audit; never auto-deleted.

Summary modal on first open of the new revision:

Addendum detected
━━━━━━━━━━━━━━━━━━━━━━━━━━
New sheets: 2     (M-107, M-108)
Replaced:   3     (M-103, M-105, RCP-201)
Removed:   0

12 zones + 4 annotations flagged pre-addendum.
  [Review affected items]   [Dismiss]

27.2 Layer 2 — Visual overlay comparison (Phase 2)

  • Render old and new layouts as two Three.js Groups in the same Scene, one at renderOrder = 0 with white tint, the other at renderOrder = 1 with red-dominant tint, both at reduced opacity (0.5).
  • Pixel-level difference is not available on vector geometry; instead, compute per-entity diff between old and new scenes using stable handles. Entities in new-but-not-old render solid red; entities in old-but-not-new render solid blue-dashed; entities in both unchanged render gray.
  • User toggles "Show diff" in the toolbar. Off by default.

27.3 Layer 3 — AI quantity delta estimation (Phase 3)

  • After Layer 2, run a background analyzer that tags clusters of changed entities into rough-area estimates — "~200 LF of supply duct added on floor 3 (new branch to added VAV boxes)".
  • Requires AI equipment detection (§29) to identify what entities belong to. Aspirational.

27.4 Data model additions

Extend Drawing:

ParentDrawingId *uint32 `json:"parent_drawing_id"`   // already exists per §15.3
VersionNumber   int32   `json:"version_number"`       // already exists per §15.3
AddendumSummaryJSON string `json:"addendum_summary_json"`  // per-sheet delta computed on ingest

DrawingZone / DrawingAnnotation:

IsPreAddendum bool `json:"is_pre_addendum"`

28. Repeating groups — typical unit × multiplier

Ported from AI-prototype/docs/content/workflows/06-takeoff.md §"Repeating Groups". Applicable to Pinley because hotels, residential towers, and hospital floors have repetitive layouts — marking one unit complete and mirroring to 180 similar rooms is exactly the "stop emailing marked-up PDFs" success criterion (§22) at scale.

28.1 Define the typical unit

  1. User draws a polygon zone (existing §10.1) around one room.
  2. Names it "Typical Unit — <name>" (e.g., "Typical Hotel Room"). The name field already supports this.
  3. New field: DrawingZone.is_typical_unit bool — marks the zone as a template.

28.2 Multiplier with variation tiers

Add DrawingZoneMultiplier table:

CREATE TABLE drawing_zone_multiplier (
  id                  uuid PK,
  zone_id             bigint FK drawing_zones,     -- the typical unit
  tier_name           text,                         -- "Base", "Larger", "Corner/end", "Penthouse"
  count               int NOT NULL,
  factor              float NOT NULL DEFAULT 1.0,  -- ×1.0, ×1.15, ×1.3, ×2.0
  note                text,
  created_at          timestamptz
);

UI: on the typical-unit detail panel, table rows like:

Tier Description Factor Count
Base Floors 2-10, standard ×1.0 180
Larger Floors 11-15 ×1.15 20
Corner/end Different layout ×1.3 16
Penthouse Custom ×2.0 4

28.3 Rollup display

Zone panel surfaces aggregate status:

Typical Hotel Room × 220 rooms (with variations):
  ◉ Complete:      44 rooms (20%)
  ◐ In progress:   120 rooms
  ◯ Not started:   56 rooms
  ⚠ Blocked:       0

Progress on the typical unit is NOT automatically applied to instances — it's a template, not a shared state. Per-instance progress is tracked independently so a field foreman can mark "Room 502" complete without touching Room 503.

28.4 Status rollup semantics (open question)

Two models are valid:

  • Independent-instance — each logical instance has its own current_status. Matches the Kool-Aid above.
  • Template-locked — changing the typical unit's zone geometry / annotations cascades to all instances as a copy-on-write.

v1 ships independent-instance. Template-locked is a v2 consideration.


29. AI equipment detection

Ported from AI-prototype/docs/content/workflows/06-takeoff.md §"AI-Powered Detection". Applicable to Pinley as a progress-tracking accelerator — instead of manually zoning 47 VAV boxes, the AI pre-identifies them and the PM just marks each as complete when work finishes.

29.1 Phase 1 — Schedule + symbol + tag cross-reference (MVP)

What the detector looks for on M-series drawings:

Source What's detected Confidence
Equipment-schedule tables (M-001, M-002) Structured tabular data — tags, descriptions, specs High
Equipment symbols on floor plans AHU rectangles, pump circles, fan symbols, VAV symbols, diffuser symbols High
Tag callouts AHU-1, P-1A, EF-3 text near symbols High (OCR + proximity)

Cross-reference produces a confirmed equipment list:

AI Equipment Detection — 92% confidence
Found on 12 sheets:

Equipment Type  Count   Tags                    Confidence
AHUs            3       AHU-1 / AHU-2 / AHU-3  High (schedule)
VAV Boxes       47      VAV-1 through VAV-47    High (schedule)
Exhaust Fans    8       EF-1 through EF-8       High (schedule)
Pumps           6       P-1A, P-1B, P-2A…      High (schedule)
Diffusers       124     (counted from plans)    Medium (symbol count)
Unit Heaters    12      UH-1 through UH-12      High (schedule)

[ Confirm all ]  [ Review individually ]  [ Add missing ]

Bulk-confirm high-confidence items; review medium / low individually. Every confirmation / correction trains the detector.

29.2 Phase 2 — Equipment + simple runs

Detector traces straight duct / pipe segments between detected equipment. Flags direction changes for user review. Medium accuracy — user confirms / corrects routes.

29.3 Phase 3 — Full route detection

Detector traces entire duct / pipe networks including branches. Aspirational; gated on real-world training data.

29.4 Data model additions

DrawingDetectedEquipment table:

CREATE TABLE drawing_detected_equipment (
  id                     uuid PK,
  drawing_id             bigint FK,
  layout_id              bigint FK,
  equipment_type         text,                    -- "AHU" | "VAV" | "EF" | "Diffuser" | ...
  tag                    text,                    -- "AHU-1"
  confidence             text,                    -- "high" | "medium" | "low"
  confidence_source      text,                    -- "schedule" | "symbol" | "tag"
  bbox                   jsonb,                   -- {x,y,w,h} in layout units
  status                 text,                    -- "detected" | "confirmed" | "rejected"
  confirmed_by_user_id   uuid FK User NULL,
  confirmed_at           timestamptz NULL,
  linked_zone_id         bigint FK drawing_zones NULL,  -- auto-create a zone on confirm
  created_at, updated_at timestamptz
);

Confirming an item auto-creates a minimal-bbox zone around the equipment, inheriting the equipment type as the zone name. This closes the loop: detection → zone → progress status.

29.5 Non-goals for Pinley v1

  • No electrical / plumbing detection. HVAC-only (M-series) for MVP.
  • No retraining UI. Confirmations feed a passive dataset; retraining is manual, ops-driven.
  • No fully-automated zoning. Every detection requires human confirmation before a zone is created.

30. Scope boundary with AI-prototype takeoff viewer

The AI-prototype PDF viewer is an estimator takeoff tool; Pinley's DWG viewer is a field progress tool. Despite cosmetic similarity, several features in the reference intentionally do not apply here. This section enumerates the exclusions so future readers don't reopen the question.

Feature In AI-prototype In Pinley Rationale
Spec palette (system + size + material, pre-built from parsed bid specs) Estimator-only; Pinley doesn't do bid-spec parsing
Route tracing with auto-fitting generation at waypoints Takeoff-only; Pinley tracks progress on already-built geometry, not measures new scope
SMACNA gauge/weight tables Feeds quantity → weight → cost; Pinley doesn't do cost
MSS SP-69 hanger spacing tables Same
Live cost breakdown during takeoff Pinley is pre-construction oversight, not pricing
Tabular takeoff mode (/bids/:id/takeoff) Estimator surface; not in Pinley scope
Takeoff-to-estimate field mapping No estimate surface in Pinley
Labor-hour derivation from production rates Cost-related
AutoBid / QuoteSoft / STACK competitive framing Different market position
Measurement tools (Linear / Count / Area / Rectangle) ✓ (§24) Useful for inspection + rough reporting
Scale calibration ✓ (§25) Required for any measurement
Sheet list + per-sheet completion tracking ✓ (§26) Natural fit for progress tracking
Addendum drawing comparison (3 layers) ✓ (§27) Critical for mid-project revisions
Repeating groups (typical unit × multiplier) ✓ (§28) Natural fit for repetitive floors / rooms
AI equipment detection ✓ (§29) Accelerates zone creation; aligned with HVAC focus
Progress markup canvas launched from daily-log entry ✓ (takeoff + daily-log) Future integration point Pinley daily-log module, if built, can invoke the viewer in "markup mode"

If a future requirement points back to any ✗ row, it's a product-boundary decision — treat it as a new spec, not a derivation from this one.


Last reviewed: 2026-04-23 · §24–30 added from AI-prototype takeoff viewer spec (viewer-core subset; takeoff-specific items intentionally excluded per §30). Authoritative source for code: this document. If source specs diverge, update this file plus a link at the top explaining which source was superseded and why.