Skip to content

RFC: read the PDF layer tree — Optional Content Groups as first-class geometry filters #85

Description

@QuisutDeus1

The heuristics exist to recover information the file already states

classifyHatchSegs in web/src/lib/oneclick.ts is a wall of tuned constants — HATCH_MIN_RUN, pitch-regularity bands, SPAN_PROTECT_RATIO, the filled-not-stroked poché exemption added after the VA plan drew its walls as solid shapes — and its entire job is to guess which segments are boundary and which are pattern.

A construction document exported from Revit, AutoCAD, or Archicad usually says so outright. PDF Optional Content Groups carry the CAD layer structure through the export, most often on the AIA CAD Layer Guidelines convention:

A-FLOR-OTLN    floor / room outlines
A-FLOR-PATT    finish hatch patterns
A-WALL-FULL    full-height partitions
A-WALL-PRHT    partial-height partitions
A-DOOR         door swings and tags
A-ANNO-*       text, dimensions, leaders
S-*            structural

OpenTakeoff reads none of it. grep -r optionalContent web/src mcp/src returns zero hits. Every path is flattened into one undifferentiated 1-bit mask and then partially un-flattened by heuristics.

Where that costs us

The work

1. Capture the OCG id during extraction. pdf.js exposes layer membership through marked-content operators in the operator list (beginMarkedContentProps carrying an OCG ref) and the id→name/visibility mapping through page.getOptionalContentConfig(). extractVectorGeometry already walks the operator list for constructPath; it emits a layerId alongside the existing meta bits and nothing else changes. The marked-content stack nests — this is a small state machine, not a lookup.

2. A layer-name normalizer. New pure module, web/src/lib/layers.ts: raw layer name → { role, confidence } where role is boundary | finish-pattern | annotation | structure | demolition | unknown. The AIA table is the easy half. The real work is the field:

  • xref-prefixed names (ARCH-FLOOR|A-WALL-FULL)
  • vendor dialects (Bentley, Archicad, Vectorworks)
  • truncated and renumbered variants (A-WALL-1, WALL, A-WALL FULL HT)
  • the degenerate case where everything sits on layer 0
  • non-English layer names

This module is where the RFC actually lives. It's self-contained, pure, table-driven, and testable without any geometry background.

3. buildMask consumes roles instead of guessing. When a page has usable layers: boundary roles go in hard, pattern/annotation/demolition are excluded outright, structure becomes a subtractive hint, and classifyHatchSegs runs only over unknown. When a page has no layers, today's path runs completely untouched. This is a short-circuit, never a replacement — a regression on unlayered sheets is a failed PR.

4. Surface it. sheet_info (MCP) returns the layer table. one_click and detect_rooms accept an optional layers: { include?, exclude? }. The canvas gets a layer visibility panel — an estimator toggling A-FLOR-PATT off to see the room boundary underneath is worth shipping on its own merit, independent of the engine work.

5. Provenance. A trace bounded by declared layers is categorically stronger evidence than one bounded by a pitch heuristic. layer_bounded: true rides the contribution.v2 wire and feeds the confidence field #60 calls for.

Known hard parts

  • pdf.js's OCG plumbing is thinly documented and the marked-content stack nests.
  • Visibility state matters: a path can be present-but-hidden in the default config. Honor it or you will trace demolition.
  • Non-conforming layer names are the majority of real sets, not the exception.
  • Some exporters flatten layers entirely. The fallback must be invisible, not a degraded mode with a warning.

Finish line

Against the benchmark corpus (#60): layered fixtures show a measurable IoU floor lift and a refusal-rate drop; unlayered fixtures show exactly zero delta. Both numbers reported in the PR, per fixture.

Explicitly out of scope

Native DWG and IFC ingestion. Both need non-JS dependencies and break the client-only architecture. That's a real lane and a separate decision — it is not this issue, and PRs that start there will be redirected.

Constraints

oneclick.ts and layers.ts stay pure — no DOM, no React. The MCP server runs the identical engine headlessly. Refusal over guessing. New failure modes ship with named regression fixtures.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestrfcOpen architectural problem with a stated finish line

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions