planlens 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
planlens-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024-2026 Sean O'Connell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: planlens
3
+ Version: 0.1.0
4
+ Summary: Drawing & submittal intelligence — deterministic geometry extraction and confidence-scored annotation constructs (leaders, dimensions, title blocks, bubbles, revision clouds) from PDF and DXF construction drawings
5
+ Author-email: Sean O'Connell <soconnell345@gmail.com>
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 4 - Beta
8
+ Classifier: Intended Audience :: Science/Research
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Scientific/Engineering
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: numpy>=2.0
20
+ Requires-Dist: ezdxf>=1.4
21
+ Requires-Dist: PyMuPDF>=1.23
22
+ Provides-Extra: raster
23
+ Requires-Dist: opencv-python-headless>=4.8; extra == "raster"
24
+ Provides-Extra: ocr
25
+ Requires-Dist: rapidocr-onnxruntime<1.3,>=1.2; extra == "ocr"
26
+ Dynamic: license-file
27
+
28
+ # planlens
29
+
30
+ Drawing & submittal intelligence: deterministic geometry extraction plus
31
+ confidence-scored annotation constructs from PDF and DXF construction
32
+ drawings.
33
+
34
+ ## Architecture
35
+
36
+ Two layers, one principle — **geometry says WHERE, vision says WHAT**.
37
+ LLM/VLM vision is unreliable on precise geometry, so a deterministic
38
+ extractor owns every coordinate and an LLM (if you attach one) owns only
39
+ semantics.
40
+
41
+ 1. **Primitive layer** (`planlens.ir`): a unified intermediate
42
+ representation — Line / Polyline / Arc / Circle / Text with
43
+ coordinates, layer, provenance, and confidence — ingested from DXF
44
+ (`ezdxf`, confidence 1.0), vector PDF (`planlens.pdf`, confidence
45
+ 1.0), or raster images (OpenCV, confidence < 1.0, `raster` extra).
46
+ Slice queries (bbox / angle / text / layer / nearest / endpoint) let
47
+ a caller request exactly the geometry it needs.
48
+ 2. **Composition layer** (`planlens.ir.queries`): named annotation
49
+ constructs assembled from primitives as confidence-scored
50
+ **proposals**, never asserted facts — leaders, dimensions, title
51
+ blocks, bubble callouts (keynotes, grid bubbles, detail marks), and
52
+ best-effort revision clouds. Every proposal carries the evidence it
53
+ was built from.
54
+
55
+ `planlens.ir.render.render_region` snips any region of a sheet to a
56
+ high-DPI PNG (optionally with numbered set-of-marks overlays) so a
57
+ vision model can answer "what is this pointing at" about a location the
58
+ geometry layer pinned down.
59
+
60
+ `planlens.ocr` (optional `[ocr]` extra) reads lettering optically off
61
+ rendered sheets — many production plots letter with stroked outlines
62
+ (no text layer at all) — and merges the results into the IR as
63
+ confidence-scored text entities in the same coordinate frame
64
+ (auto-detects sideways-plotted sheets and PDF page rotation, and
65
+ corrects the engine's silent corner-order rotation on flipped or
66
+ vertical lines — verified within ~2 pt of the vector-ingest frame on
67
+ /Rotate=0/90/180/270 and both vertical reading directions).
68
+
69
+ `planlens.ir.align.fit_plot_transform` fits the model-space-to-plot
70
+ transform (axis rotation + scale + offset) from anchor geometry, so
71
+ native CAD entities can be located on the plotted page.
72
+
73
+ ## Capability status (kept honest)
74
+
75
+ - **Proven on real agency sheets**: bubble callouts (40/40 count match
76
+ on a dense municipal standard detail), region rendering, endpoint /
77
+ text-anchored queries, multi-page drawing-set search, OCR text
78
+ recovery on no-text-layer plots (88-100% truth-text coverage, median
79
+ coordinate error 2.2-15.5 pt per the committed ocr_coverage_check
80
+ convention on the validation sheets), plot-transform
81
+ fitting (0.02-0.03 pt rms on rotated real plots; guarded against the
82
+ degenerate-scale and chance-match regimes — random anchors on a dense
83
+ 10k-entity sheet now return None in 60/60 trials while every true fit
84
+ still passes), and native DXF annotation ingest (LEADER/MULTILEADER/
85
+ DIMENSION/ATTRIB as first-class entities at confidence 1.0, surfaced
86
+ by find_leaders/find_dimensions as evidence "native_dxf"; the
87
+ ground-truth extractor lives at planlens.dxf.truth and reproduces the
88
+ committed corpus's MODEL-SPACE annotation content exactly — it does
89
+ not yet extract paper-space layouts, which the committed files also
90
+ carry, so regenerating the corpus with it would drop those blocks).
91
+ - **Partially proven on real sheets**: leader detection reaches 21/25
92
+ native-truth tips on the validation set (11/25 before the 2026-09-05
93
+ arrowhead-representation work; 0/25 before the plot-transform fit).
94
+ The 2026-09-05 gain came from accepting 3-vertex OPEN arrow chains —
95
+ real plotters draw an arrow outline minus one whole edge, in both
96
+ base+leg and chevron flavors — behind a shape gate calibrated to
97
+ measured real arrows. PRECISION IS SHEET-DEPENDENT and verified by
98
+ rendering: the gate does NOT filter SHX letterforms on
99
+ annotation-free, lettering-heavy sheets (hundreds of
100
+ letterform leader proposals at default confidence there — treat
101
+ leader RECALL as proven and leader precision as unproven outside
102
+ annotation-rich sheets). Dimension detection on the same sheets
103
+ reaches 13/16 native defpoints (from 1/16) via a split-shaft
104
+ pairing leg: two collinear opposed-arrow half-shafts around a
105
+ centered text gap, plus the outside-arrows narrow style, both with
106
+ witness-line corroboration; proposal ends are the arrow apexes (the
107
+ CAD defpoints). Witness lines require arrowhead-scale length and
108
+ un-corroborated no-text proposals cap at confidence 0.45 — the
109
+ worst sheet's default output went from 44 proposals at ~0 precision
110
+ to 11 with 8 touching native truth. Independent render-adjudication
111
+ of every non-matching detection (2026-09-05): on the curb-ramp
112
+ sheet 9 of 10 were REAL manually-drafted dimensions the native
113
+ truth cannot record (measured precision ~14/15); on note-heavy SHX
114
+ sheets confidence 1.0 does NOT preclude glyph junk (21.01: ~5/14
115
+ semantic precision) — verify visually there. Residual misses:
116
+ tips with no plotted arrow fragments, sparse dots inside stipple,
117
+ and witness-crossing vertical dimension layouts (not yet modeled).
118
+ - **Best-effort tier**: revision clouds (drafting-practice dependent).
119
+
120
+ ## Install
121
+
122
+ ```
123
+ pip install planlens # DXF + vector-PDF ingest
124
+ pip install "planlens[raster]" # + raster/scanned-sheet tracing
125
+ pip install "planlens[ocr]" # + optical text for stroked/scanned sheets
126
+ ```
127
+
128
+ The `[ocr]` extra installs RapidOCR + onnxruntime with PP-OCR models
129
+ inside the wheel (no runtime downloads; all-permissive licenses:
130
+ Apache-2.0/MIT/BSD).
131
+
132
+ **OpenCV variants — pick per environment.** Every published rapidocr
133
+ distribution (`rapidocr-onnxruntime` 1.x and the unified `rapidocr`
134
+ 2/3.x alike) hard-requires the full GUI `opencv-python` (~112 MB),
135
+ while the `[raster]` extra uses `opencv-python-headless`; pip cannot
136
+ express "either variant", and installing both leaves two distributions
137
+ owning the `cv2` namespace (works, but uninstalling either can break
138
+ the other). Decision:
139
+
140
+ - **Desktop / notebook**: `pip install "planlens[raster,ocr]"` as
141
+ above — the GUI build wins the namespace and everything works.
142
+ - **Server / headless deploy** (Databricks, TinyApps — no GUI libs):
143
+ skip the `[ocr]` extra and install the engine without its metadata
144
+ deps; the OCR leg needs only the cv2 APIs headless provides
145
+ (verified end-to-end in a clean headless-only venv, 2026-09-05):
146
+
147
+ ```
148
+ pip install "planlens[raster]"
149
+ pip install --no-deps "rapidocr-onnxruntime==1.2.3"
150
+ pip install "onnxruntime>=1.7" pyclipper shapely pillow pyyaml six
151
+ ```
152
+
153
+ Pin the rapidocr version you validated — `--no-deps` means ITS
154
+ dependency list is being supplied by hand, so an unpinned upgrade
155
+ could silently need something new. (1.2.3 is the newest wheel that
156
+ installs on Python 3.14 today; newer versions keep the same runtime
157
+ set — re-verify when bumping.)
@@ -0,0 +1,130 @@
1
+ # planlens
2
+
3
+ Drawing & submittal intelligence: deterministic geometry extraction plus
4
+ confidence-scored annotation constructs from PDF and DXF construction
5
+ drawings.
6
+
7
+ ## Architecture
8
+
9
+ Two layers, one principle — **geometry says WHERE, vision says WHAT**.
10
+ LLM/VLM vision is unreliable on precise geometry, so a deterministic
11
+ extractor owns every coordinate and an LLM (if you attach one) owns only
12
+ semantics.
13
+
14
+ 1. **Primitive layer** (`planlens.ir`): a unified intermediate
15
+ representation — Line / Polyline / Arc / Circle / Text with
16
+ coordinates, layer, provenance, and confidence — ingested from DXF
17
+ (`ezdxf`, confidence 1.0), vector PDF (`planlens.pdf`, confidence
18
+ 1.0), or raster images (OpenCV, confidence < 1.0, `raster` extra).
19
+ Slice queries (bbox / angle / text / layer / nearest / endpoint) let
20
+ a caller request exactly the geometry it needs.
21
+ 2. **Composition layer** (`planlens.ir.queries`): named annotation
22
+ constructs assembled from primitives as confidence-scored
23
+ **proposals**, never asserted facts — leaders, dimensions, title
24
+ blocks, bubble callouts (keynotes, grid bubbles, detail marks), and
25
+ best-effort revision clouds. Every proposal carries the evidence it
26
+ was built from.
27
+
28
+ `planlens.ir.render.render_region` snips any region of a sheet to a
29
+ high-DPI PNG (optionally with numbered set-of-marks overlays) so a
30
+ vision model can answer "what is this pointing at" about a location the
31
+ geometry layer pinned down.
32
+
33
+ `planlens.ocr` (optional `[ocr]` extra) reads lettering optically off
34
+ rendered sheets — many production plots letter with stroked outlines
35
+ (no text layer at all) — and merges the results into the IR as
36
+ confidence-scored text entities in the same coordinate frame
37
+ (auto-detects sideways-plotted sheets and PDF page rotation, and
38
+ corrects the engine's silent corner-order rotation on flipped or
39
+ vertical lines — verified within ~2 pt of the vector-ingest frame on
40
+ /Rotate=0/90/180/270 and both vertical reading directions).
41
+
42
+ `planlens.ir.align.fit_plot_transform` fits the model-space-to-plot
43
+ transform (axis rotation + scale + offset) from anchor geometry, so
44
+ native CAD entities can be located on the plotted page.
45
+
46
+ ## Capability status (kept honest)
47
+
48
+ - **Proven on real agency sheets**: bubble callouts (40/40 count match
49
+ on a dense municipal standard detail), region rendering, endpoint /
50
+ text-anchored queries, multi-page drawing-set search, OCR text
51
+ recovery on no-text-layer plots (88-100% truth-text coverage, median
52
+ coordinate error 2.2-15.5 pt per the committed ocr_coverage_check
53
+ convention on the validation sheets), plot-transform
54
+ fitting (0.02-0.03 pt rms on rotated real plots; guarded against the
55
+ degenerate-scale and chance-match regimes — random anchors on a dense
56
+ 10k-entity sheet now return None in 60/60 trials while every true fit
57
+ still passes), and native DXF annotation ingest (LEADER/MULTILEADER/
58
+ DIMENSION/ATTRIB as first-class entities at confidence 1.0, surfaced
59
+ by find_leaders/find_dimensions as evidence "native_dxf"; the
60
+ ground-truth extractor lives at planlens.dxf.truth and reproduces the
61
+ committed corpus's MODEL-SPACE annotation content exactly — it does
62
+ not yet extract paper-space layouts, which the committed files also
63
+ carry, so regenerating the corpus with it would drop those blocks).
64
+ - **Partially proven on real sheets**: leader detection reaches 21/25
65
+ native-truth tips on the validation set (11/25 before the 2026-09-05
66
+ arrowhead-representation work; 0/25 before the plot-transform fit).
67
+ The 2026-09-05 gain came from accepting 3-vertex OPEN arrow chains —
68
+ real plotters draw an arrow outline minus one whole edge, in both
69
+ base+leg and chevron flavors — behind a shape gate calibrated to
70
+ measured real arrows. PRECISION IS SHEET-DEPENDENT and verified by
71
+ rendering: the gate does NOT filter SHX letterforms on
72
+ annotation-free, lettering-heavy sheets (hundreds of
73
+ letterform leader proposals at default confidence there — treat
74
+ leader RECALL as proven and leader precision as unproven outside
75
+ annotation-rich sheets). Dimension detection on the same sheets
76
+ reaches 13/16 native defpoints (from 1/16) via a split-shaft
77
+ pairing leg: two collinear opposed-arrow half-shafts around a
78
+ centered text gap, plus the outside-arrows narrow style, both with
79
+ witness-line corroboration; proposal ends are the arrow apexes (the
80
+ CAD defpoints). Witness lines require arrowhead-scale length and
81
+ un-corroborated no-text proposals cap at confidence 0.45 — the
82
+ worst sheet's default output went from 44 proposals at ~0 precision
83
+ to 11 with 8 touching native truth. Independent render-adjudication
84
+ of every non-matching detection (2026-09-05): on the curb-ramp
85
+ sheet 9 of 10 were REAL manually-drafted dimensions the native
86
+ truth cannot record (measured precision ~14/15); on note-heavy SHX
87
+ sheets confidence 1.0 does NOT preclude glyph junk (21.01: ~5/14
88
+ semantic precision) — verify visually there. Residual misses:
89
+ tips with no plotted arrow fragments, sparse dots inside stipple,
90
+ and witness-crossing vertical dimension layouts (not yet modeled).
91
+ - **Best-effort tier**: revision clouds (drafting-practice dependent).
92
+
93
+ ## Install
94
+
95
+ ```
96
+ pip install planlens # DXF + vector-PDF ingest
97
+ pip install "planlens[raster]" # + raster/scanned-sheet tracing
98
+ pip install "planlens[ocr]" # + optical text for stroked/scanned sheets
99
+ ```
100
+
101
+ The `[ocr]` extra installs RapidOCR + onnxruntime with PP-OCR models
102
+ inside the wheel (no runtime downloads; all-permissive licenses:
103
+ Apache-2.0/MIT/BSD).
104
+
105
+ **OpenCV variants — pick per environment.** Every published rapidocr
106
+ distribution (`rapidocr-onnxruntime` 1.x and the unified `rapidocr`
107
+ 2/3.x alike) hard-requires the full GUI `opencv-python` (~112 MB),
108
+ while the `[raster]` extra uses `opencv-python-headless`; pip cannot
109
+ express "either variant", and installing both leaves two distributions
110
+ owning the `cv2` namespace (works, but uninstalling either can break
111
+ the other). Decision:
112
+
113
+ - **Desktop / notebook**: `pip install "planlens[raster,ocr]"` as
114
+ above — the GUI build wins the namespace and everything works.
115
+ - **Server / headless deploy** (Databricks, TinyApps — no GUI libs):
116
+ skip the `[ocr]` extra and install the engine without its metadata
117
+ deps; the OCR leg needs only the cv2 APIs headless provides
118
+ (verified end-to-end in a clean headless-only venv, 2026-09-05):
119
+
120
+ ```
121
+ pip install "planlens[raster]"
122
+ pip install --no-deps "rapidocr-onnxruntime==1.2.3"
123
+ pip install "onnxruntime>=1.7" pyclipper shapely pillow pyyaml six
124
+ ```
125
+
126
+ Pin the rapidocr version you validated — `--no-deps` means ITS
127
+ dependency list is being supplied by hand, so an unpinned upgrade
128
+ could silently need something new. (1.2.3 is the newest wheel that
129
+ installs on Python 3.14 today; newer versions keep the same runtime
130
+ set — re-verify when bumping.)
@@ -0,0 +1,8 @@
1
+ """planlens — drawing & submittal intelligence.
2
+
3
+ Deterministic geometry extraction (:mod:`planlens.ir`, :mod:`planlens.pdf`,
4
+ :mod:`planlens.dxf`) plus confidence-scored annotation constructs
5
+ (:mod:`planlens.ir.queries`) from PDF/DXF construction drawings.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,10 @@
1
+ """DXF-side utilities: unit detection/conversion + native-annotation truth
2
+ extraction (:mod:`planlens.dxf.truth`). Native LEADER / MULTILEADER /
3
+ DIMENSION / ATTRIB ingest into the IR lives in :func:`planlens.ir.from_dxf`."""
4
+
5
+ from planlens.dxf.units import ( # noqa: F401
6
+ UNIT_FACTORS,
7
+ convert_coords,
8
+ detect_units_from_header,
9
+ )
10
+ from planlens.dxf.truth import extract_native_annotations # noqa: F401
@@ -0,0 +1,153 @@
1
+ """Native-annotation extraction from a DXF — the ground-truth API.
2
+
3
+ Extracts the annotation entities a CAD file DECLARES — LEADER,
4
+ MULTILEADER, DIMENSION, TEXT/MTEXT, INSERT — as plain JSON-able dicts in
5
+ raw model-space coordinates (no unit conversion, no IR wrapping). This is
6
+ the extractor behind the drawing-intelligence ground-truth corpus: the
7
+ composition heuristics are scored against what the native CAD file says
8
+ was drawn, so this module deliberately reports raw values (MTEXT
9
+ formatting codes retained, raw group-70 ``dimtype``, coordinates rounded
10
+ to 4 decimals) exactly as the committed ``*.truth.json`` files do.
11
+
12
+ For the IR-level view of the same entities (unit-converted, plain text,
13
+ first-class :class:`planlens.ir.results.Leader` / ``Dimension`` entities
14
+ at confidence 1.0) use :func:`planlens.ir.from_dxf` — that is the surface
15
+ queries and agents consume; this one is for scoring and corpus building.
16
+
17
+ Usage::
18
+
19
+ from planlens.dxf.truth import extract_native_annotations
20
+ truth = extract_native_annotations("sheet.dxf")
21
+ truth["spaces"]["model"]["dimensions"][0]["defpoint"]
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import os
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ __all__ = ["extract_native_annotations"]
30
+
31
+
32
+ def _xy(v, nd: int = 4) -> List[float]:
33
+ try:
34
+ x, y = v.x, v.y
35
+ except AttributeError: # plain tuple/list vertex (older ezdxf paths)
36
+ x, y = v[0], v[1]
37
+ return [round(float(x), nd), round(float(y), nd)]
38
+
39
+
40
+ def _space_annotations(space) -> Dict[str, List[Dict[str, Any]]]:
41
+ out: Dict[str, List[Dict[str, Any]]] = {
42
+ "leaders": [], "multileaders": [], "dimensions": [],
43
+ "text": [], "inserts": [],
44
+ }
45
+ for e in space:
46
+ etype = e.dxftype()
47
+ layer = e.dxf.get("layer", "0")
48
+ if etype == "LEADER":
49
+ out["leaders"].append({
50
+ "vertices": [_xy(v) for v in e.vertices],
51
+ "layer": layer,
52
+ "has_arrowhead": bool(e.dxf.get("has_arrowhead", 1)),
53
+ "annotation_handle": e.dxf.get("annotation_handle", None)
54
+ or "",
55
+ })
56
+ elif etype == "MULTILEADER":
57
+ try:
58
+ ctx = e.context
59
+ mtext = getattr(ctx, "mtext", None)
60
+ text = (mtext.default_content
61
+ if mtext is not None else "") or ""
62
+ lines = [[_xy(v) for v in ln.vertices]
63
+ for ml in ctx.leaders for ln in ml.lines]
64
+ except Exception:
65
+ text, lines = "", []
66
+ out["multileaders"].append({
67
+ "text": text,
68
+ "leader_lines": lines,
69
+ "layer": layer,
70
+ })
71
+ elif etype == "DIMENSION":
72
+ try:
73
+ meas = round(float(e.get_measurement()), 4)
74
+ except Exception:
75
+ meas = 0.0
76
+ dp = e.dxf.get("defpoint", None)
77
+ tm = e.dxf.get("text_midpoint", None)
78
+ out["dimensions"].append({
79
+ "dimtype": int(e.dxf.get("dimtype", 0) or 0),
80
+ "text": e.dxf.get("text", ""),
81
+ "defpoint": _xy(dp) if dp is not None else None,
82
+ "text_midpoint": _xy(tm) if tm is not None else None,
83
+ "measurement": meas,
84
+ "layer": layer,
85
+ })
86
+ elif etype in ("TEXT", "MTEXT"):
87
+ ins = e.dxf.get("insert", None)
88
+ if etype == "TEXT":
89
+ content = e.dxf.get("text", "")
90
+ height = e.dxf.get("height", 0.0)
91
+ else:
92
+ content = e.text
93
+ height = e.dxf.get("char_height", 0.0)
94
+ out["text"].append({
95
+ "type": etype,
96
+ "text": content,
97
+ "insert": _xy(ins) if ins is not None else None,
98
+ "height": round(float(height), 4),
99
+ "rotation": round(float(e.dxf.get("rotation", 0.0)), 2),
100
+ "layer": layer,
101
+ })
102
+ elif etype == "INSERT":
103
+ ins = e.dxf.get("insert", None)
104
+ rec: Dict[str, Any] = {
105
+ "name": e.dxf.get("name", ""),
106
+ "insert": _xy(ins) if ins is not None else None,
107
+ "layer": layer,
108
+ }
109
+ attribs = [{"tag": a.dxf.get("tag", ""),
110
+ "text": a.dxf.get("text", ""),
111
+ "insert": _xy(a.dxf.insert),
112
+ "height": round(float(a.dxf.get("height", 0.0)), 4)}
113
+ for a in e.attribs if a.dxf.get("text", "")]
114
+ if attribs:
115
+ rec["attribs"] = attribs
116
+ out["inserts"].append(rec)
117
+ return out
118
+
119
+
120
+ def extract_native_annotations(filepath: Optional[str] = None,
121
+ doc=None) -> Dict[str, Any]:
122
+ """Extract native annotation entities from a DXF, truth-file schema.
123
+
124
+ Pass a ``filepath`` or an already-open ezdxf ``doc``. Returns::
125
+
126
+ {"source_dxf": <basename>,
127
+ "spaces": {"model": {"leaders": [...], "multileaders": [...],
128
+ "dimensions": [...], "text": [...],
129
+ "inserts": [...]}}}
130
+
131
+ Model-space coordinates are RAW drawing units rounded to 4 decimals;
132
+ MTEXT keeps its inline formatting codes; ``dimtype`` is the raw DXF
133
+ group-70 value. ``inserts`` additionally carry an ``attribs`` list
134
+ (tag/text/insert/height) when the reference has attribute values — a
135
+ superset of the original truth schema, additive only.
136
+ """
137
+ if (filepath is None) == (doc is None):
138
+ raise ValueError("pass exactly one of filepath / doc")
139
+ if doc is None:
140
+ try:
141
+ import ezdxf
142
+ except ImportError as exc:
143
+ raise ImportError("ezdxf is required: pip install ezdxf>=1.4"
144
+ ) from exc
145
+ doc = ezdxf.readfile(filepath)
146
+ name = os.path.basename(filepath)
147
+ else:
148
+ name = getattr(doc, "filename", None) or ""
149
+ name = os.path.basename(name) if name else ""
150
+ return {
151
+ "source_dxf": name,
152
+ "spaces": {"model": _space_annotations(doc.modelspace())},
153
+ }
@@ -0,0 +1,92 @@
1
+ """
2
+ Unit conversion utilities for DXF geometry import.
3
+
4
+ Converts coordinates from DXF drawing units (mm, cm, ft, in) to SI meters.
5
+ Also detects units from the $INSUNITS DXF header variable.
6
+
7
+ $INSUNITS values (per DXF spec):
8
+ 0 = Unitless, 1 = Inches, 2 = Feet, 3 = Miles, 4 = Millimeters,
9
+ 5 = Centimeters, 6 = Meters, 7 = Kilometers, ...
10
+ """
11
+
12
+ from typing import List, Optional, Tuple
13
+
14
+ # Conversion factors to meters
15
+ UNIT_FACTORS = {
16
+ "m": 1.0,
17
+ "mm": 0.001,
18
+ "cm": 0.01,
19
+ "ft": 0.3048,
20
+ "in": 0.0254,
21
+ }
22
+
23
+ # DXF $INSUNITS header variable mapping
24
+ _INSUNITS_MAP = {
25
+ 1: "in",
26
+ 2: "ft",
27
+ 4: "mm",
28
+ 5: "cm",
29
+ 6: "m",
30
+ }
31
+
32
+
33
+ def convert_coords(
34
+ points: List[Tuple[float, float]],
35
+ from_units: str,
36
+ to_units: str = "m",
37
+ ) -> List[Tuple[float, float]]:
38
+ """Convert a list of (x, y) coordinate pairs between unit systems.
39
+
40
+ Parameters
41
+ ----------
42
+ points : list of (float, float)
43
+ Input coordinates.
44
+ from_units : str
45
+ Source units ('m', 'mm', 'cm', 'ft', 'in').
46
+ to_units : str
47
+ Target units. Default 'm'.
48
+
49
+ Returns
50
+ -------
51
+ list of (float, float)
52
+ Converted coordinates.
53
+
54
+ Raises
55
+ ------
56
+ ValueError
57
+ If either unit string is not recognized.
58
+ """
59
+ if from_units not in UNIT_FACTORS:
60
+ raise ValueError(
61
+ f"Unknown source unit '{from_units}'. "
62
+ f"Supported: {sorted(UNIT_FACTORS.keys())}"
63
+ )
64
+ if to_units not in UNIT_FACTORS:
65
+ raise ValueError(
66
+ f"Unknown target unit '{to_units}'. "
67
+ f"Supported: {sorted(UNIT_FACTORS.keys())}"
68
+ )
69
+ if from_units == to_units:
70
+ return list(points)
71
+ factor = UNIT_FACTORS[from_units] / UNIT_FACTORS[to_units]
72
+ return [(x * factor, y * factor) for x, y in points]
73
+
74
+
75
+ def detect_units_from_header(doc) -> Optional[str]:
76
+ """Read $INSUNITS from a DXF document header and return unit string.
77
+
78
+ Parameters
79
+ ----------
80
+ doc : ezdxf.document.Drawing
81
+ An ezdxf document object.
82
+
83
+ Returns
84
+ -------
85
+ str or None
86
+ Unit string ('m', 'ft', 'mm', etc.) or None if not set / unknown.
87
+ """
88
+ try:
89
+ insunits = doc.header.get("$INSUNITS", 0)
90
+ except Exception:
91
+ return None
92
+ return _INSUNITS_MAP.get(insunits, None)
@@ -0,0 +1,65 @@
1
+ """
2
+ Drawing IR — an LLM-ready intermediate representation for a drawing page.
3
+
4
+ Design north star: LLM vision is unreliable on precise geometry, so the
5
+ DETERMINISTIC extractor owns coordinates and the LLM owns semantics. A drawing
6
+ (DXF, PDF vector, or raster scan) is digitized once into a unified
7
+ :class:`DrawingIR` — every line/polyline/arc/circle/text with coordinates,
8
+ provenance and per-entity confidence — and the LLM then requests *slices* of it
9
+ through :mod:`planlens.ir.queries` (spatial windows, angle bands, text near an
10
+ entity, layer/color groups) instead of interpreting pixels.
11
+
12
+ Quick start::
13
+
14
+ from planlens.ir import from_dxf, queries
15
+
16
+ ir = from_dxf("section.dxf") # exact CAD geometry -> DrawingIR
17
+ print(ir.summary())
18
+ surf = queries.candidate_ground_surface(ir) # a PROPOSAL to confirm
19
+ labels = queries.text_items(ir, pattern="clay")
20
+
21
+ Ingest legs: :func:`from_dxf` (ezdxf, confidence 1.0), :func:`from_pdf_vector`
22
+ (PyMuPDF + the pdf_import scale module, confidence 1.0), :func:`from_raster`
23
+ (OpenCV tracing, confidence < 1.0). See ``DESIGN.md`` for schema, confidence /
24
+ provenance semantics, and the raster leg's honest limits.
25
+ """
26
+
27
+ from planlens.ir.results import (
28
+ Arc,
29
+ Circle,
30
+ Dimension,
31
+ DrawingIR,
32
+ Entity,
33
+ Leader,
34
+ Line,
35
+ Polyline,
36
+ Region,
37
+ TextItem,
38
+ entity_from_dict,
39
+ )
40
+ from planlens.ir.ingest import from_dxf, from_pdf_vector, from_raster
41
+ from planlens.ir.render import render_region
42
+ from planlens.ir import queries
43
+
44
+ __all__ = [
45
+ # schema
46
+ "DrawingIR",
47
+ "Entity",
48
+ "Line",
49
+ "Polyline",
50
+ "Arc",
51
+ "Circle",
52
+ "TextItem",
53
+ "Region",
54
+ "Leader",
55
+ "Dimension",
56
+ "entity_from_dict",
57
+ # ingest
58
+ "from_dxf",
59
+ "from_pdf_vector",
60
+ "from_raster",
61
+ # region-snip vision primitive (PyMuPDF imported lazily inside)
62
+ "render_region",
63
+ # queries (module)
64
+ "queries",
65
+ ]