laman 0.0.1__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.
laman-0.0.1/Cargo.lock ADDED
@@ -0,0 +1,211 @@
1
+ # This file is automatically @generated by Cargo.
2
+ # It is not intended for manual editing.
3
+ version = 4
4
+
5
+ [[package]]
6
+ name = "autocfg"
7
+ version = "1.5.1"
8
+ source = "registry+https://github.com/rust-lang/crates.io-index"
9
+ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
10
+
11
+ [[package]]
12
+ name = "heck"
13
+ version = "0.5.0"
14
+ source = "registry+https://github.com/rust-lang/crates.io-index"
15
+ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
16
+
17
+ [[package]]
18
+ name = "indoc"
19
+ version = "2.0.8"
20
+ source = "registry+https://github.com/rust-lang/crates.io-index"
21
+ checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
22
+ dependencies = [
23
+ "rustversion",
24
+ ]
25
+
26
+ [[package]]
27
+ name = "laman"
28
+ version = "0.0.1"
29
+ dependencies = [
30
+ "thiserror",
31
+ ]
32
+
33
+ [[package]]
34
+ name = "laman-py"
35
+ version = "0.0.1"
36
+ dependencies = [
37
+ "laman",
38
+ "pyo3",
39
+ ]
40
+
41
+ [[package]]
42
+ name = "libc"
43
+ version = "0.2.191"
44
+ source = "registry+https://github.com/rust-lang/crates.io-index"
45
+ checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
46
+
47
+ [[package]]
48
+ name = "memoffset"
49
+ version = "0.9.1"
50
+ source = "registry+https://github.com/rust-lang/crates.io-index"
51
+ checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
52
+ dependencies = [
53
+ "autocfg",
54
+ ]
55
+
56
+ [[package]]
57
+ name = "once_cell"
58
+ version = "1.21.4"
59
+ source = "registry+https://github.com/rust-lang/crates.io-index"
60
+ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
61
+
62
+ [[package]]
63
+ name = "portable-atomic"
64
+ version = "1.15.0"
65
+ source = "registry+https://github.com/rust-lang/crates.io-index"
66
+ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
67
+
68
+ [[package]]
69
+ name = "proc-macro2"
70
+ version = "1.0.107"
71
+ source = "registry+https://github.com/rust-lang/crates.io-index"
72
+ checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
73
+ dependencies = [
74
+ "unicode-ident",
75
+ ]
76
+
77
+ [[package]]
78
+ name = "pyo3"
79
+ version = "0.26.0"
80
+ source = "registry+https://github.com/rust-lang/crates.io-index"
81
+ checksum = "7ba0117f4212101ee6544044dae45abe1083d30ce7b29c4b5cbdfa2354e07383"
82
+ dependencies = [
83
+ "indoc",
84
+ "libc",
85
+ "memoffset",
86
+ "once_cell",
87
+ "portable-atomic",
88
+ "pyo3-build-config",
89
+ "pyo3-ffi",
90
+ "pyo3-macros",
91
+ "unindent",
92
+ ]
93
+
94
+ [[package]]
95
+ name = "pyo3-build-config"
96
+ version = "0.26.0"
97
+ source = "registry+https://github.com/rust-lang/crates.io-index"
98
+ checksum = "4fc6ddaf24947d12a9aa31ac65431fb1b851b8f4365426e182901eabfb87df5f"
99
+ dependencies = [
100
+ "target-lexicon",
101
+ ]
102
+
103
+ [[package]]
104
+ name = "pyo3-ffi"
105
+ version = "0.26.0"
106
+ source = "registry+https://github.com/rust-lang/crates.io-index"
107
+ checksum = "025474d3928738efb38ac36d4744a74a400c901c7596199e20e45d98eb194105"
108
+ dependencies = [
109
+ "libc",
110
+ "pyo3-build-config",
111
+ ]
112
+
113
+ [[package]]
114
+ name = "pyo3-macros"
115
+ version = "0.26.0"
116
+ source = "registry+https://github.com/rust-lang/crates.io-index"
117
+ checksum = "2e64eb489f22fe1c95911b77c44cc41e7c19f3082fc81cce90f657cdc42ffded"
118
+ dependencies = [
119
+ "proc-macro2",
120
+ "pyo3-macros-backend",
121
+ "quote",
122
+ "syn 2.0.119",
123
+ ]
124
+
125
+ [[package]]
126
+ name = "pyo3-macros-backend"
127
+ version = "0.26.0"
128
+ source = "registry+https://github.com/rust-lang/crates.io-index"
129
+ checksum = "100246c0ecf400b475341b8455a9213344569af29a3c841d29270e53102e0fcf"
130
+ dependencies = [
131
+ "heck",
132
+ "proc-macro2",
133
+ "pyo3-build-config",
134
+ "quote",
135
+ "syn 2.0.119",
136
+ ]
137
+
138
+ [[package]]
139
+ name = "quote"
140
+ version = "1.0.47"
141
+ source = "registry+https://github.com/rust-lang/crates.io-index"
142
+ checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
143
+ dependencies = [
144
+ "proc-macro2",
145
+ ]
146
+
147
+ [[package]]
148
+ name = "rustversion"
149
+ version = "1.0.23"
150
+ source = "registry+https://github.com/rust-lang/crates.io-index"
151
+ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
152
+
153
+ [[package]]
154
+ name = "syn"
155
+ version = "2.0.119"
156
+ source = "registry+https://github.com/rust-lang/crates.io-index"
157
+ checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
158
+ dependencies = [
159
+ "proc-macro2",
160
+ "quote",
161
+ "unicode-ident",
162
+ ]
163
+
164
+ [[package]]
165
+ name = "syn"
166
+ version = "3.0.9"
167
+ source = "registry+https://github.com/rust-lang/crates.io-index"
168
+ checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
169
+ dependencies = [
170
+ "proc-macro2",
171
+ "quote",
172
+ "unicode-ident",
173
+ ]
174
+
175
+ [[package]]
176
+ name = "target-lexicon"
177
+ version = "0.13.5"
178
+ source = "registry+https://github.com/rust-lang/crates.io-index"
179
+ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
180
+
181
+ [[package]]
182
+ name = "thiserror"
183
+ version = "2.0.21"
184
+ source = "registry+https://github.com/rust-lang/crates.io-index"
185
+ checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
186
+ dependencies = [
187
+ "thiserror-impl",
188
+ ]
189
+
190
+ [[package]]
191
+ name = "thiserror-impl"
192
+ version = "2.0.21"
193
+ source = "registry+https://github.com/rust-lang/crates.io-index"
194
+ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
195
+ dependencies = [
196
+ "proc-macro2",
197
+ "quote",
198
+ "syn 3.0.9",
199
+ ]
200
+
201
+ [[package]]
202
+ name = "unicode-ident"
203
+ version = "1.0.27"
204
+ source = "registry+https://github.com/rust-lang/crates.io-index"
205
+ checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
206
+
207
+ [[package]]
208
+ name = "unindent"
209
+ version = "0.2.4"
210
+ source = "registry+https://github.com/rust-lang/crates.io-index"
211
+ checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
laman-0.0.1/Cargo.toml ADDED
@@ -0,0 +1,39 @@
1
+ # Virtual workspace manifest. The crates and the layer rule between them are
2
+ # SEED.md §7 until docs/ARCHITECTURE.md is written (SEED.md §10.2).
3
+ [workspace]
4
+ resolver = "3"
5
+ members = ["crates/*"]
6
+
7
+ [workspace.package]
8
+ version = "0.0.1"
9
+ edition = "2024"
10
+ rust-version = "1.85"
11
+ license = "MIT OR Apache-2.0"
12
+ repository = "https://github.com/arris-labs/laman"
13
+ homepage = "https://github.com/arris-labs/laman"
14
+ # Resolved relative to this manifest, so every member inherits the one
15
+ # workspace README as its crates.io front page.
16
+ readme = "README.md"
17
+
18
+ [workspace.dependencies]
19
+ # Internal crates: path plus an exact version pin, so a version bump that
20
+ # misses one fails `cargo check` (.agents/rules/git.md §The version).
21
+ laman = { path = "crates/laman", version = "=0.0.1" }
22
+
23
+ # External crates, pinned here and used with `workspace = true` (SEED.md §7).
24
+ thiserror = "2.0"
25
+ # `laman-py` only, and never on wasm32. Pinned to the minor, as in arris.
26
+ # `extension-module` is `laman-py`'s own feature, set by maturin.
27
+ pyo3 = { version = "0.26", default-features = false, features = ["macros", "abi3-py310"] }
28
+
29
+ # Every crate opts in with `[lints] workspace = true` (.agents/rules/solver.md §API).
30
+ [workspace.lints.rust]
31
+ unsafe_code = "forbid"
32
+ missing_docs = "warn"
33
+
34
+ # No panics on any input (SEED.md §5): the hook and CI run clippy with
35
+ # `-D warnings`, so these are errors there. Tests may unwrap (clippy.toml).
36
+ [workspace.lints.clippy]
37
+ unwrap_used = "warn"
38
+ expect_used = "warn"
39
+ panic = "warn"
laman-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.4
2
+ Name: laman
3
+ Version: 0.0.1
4
+ Classifier: Development Status :: 2 - Pre-Alpha
5
+ Classifier: Intended Audience :: Developers
6
+ Classifier: Intended Audience :: Science/Research
7
+ Classifier: Programming Language :: Rust
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
10
+ Classifier: Typing :: Typed
11
+ Requires-Dist: pytest>=8 ; extra == 'test'
12
+ Requires-Dist: mypy>=1.18 ; extra == 'test'
13
+ Provides-Extra: test
14
+ Summary: A deterministic 2D geometric constraint solver in pure Rust. Early release: the generic rigidity count of bar frameworks (Laman's theorem)
15
+ Keywords: constraint solver,cad,sketch,geometry,rigidity
16
+ License: MIT OR Apache-2.0
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
19
+ Project-URL: Repository, https://github.com/arris-labs/laman
20
+
21
+ # laman
22
+
23
+ A deterministic 2D geometric constraint solver in pure Rust, for Rust and
24
+ Python, built to be trusted.
25
+
26
+ `laman` is a library for sketchers, code-CAD tools, mechanism designers and
27
+ anything else that places points, lines, circles and arcs under geometric
28
+ and dimensional constraints. Its aim: the exact remaining degrees of
29
+ freedom of a sketch, the constraints to blame when it cannot be satisfied,
30
+ smooth interactive dragging, and the same bits on every platform, under
31
+ MIT OR Apache-2.0.
32
+
33
+ ## Status
34
+
35
+ Early. The sketch solver is being designed and built in the open
36
+ ([`SEED.md`](https://github.com/arris-labs/laman/blob/main/SEED.md) is the
37
+ charter). This release holds the combinatorial core the project is named
38
+ after: the **generic rigidity count of a bar framework** in the plane.
39
+
40
+ ```rust
41
+ // A square flexes: one internal freedom. A diagonal makes it rigid,
42
+ // and a second diagonal is redundant.
43
+ let bars = [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (1, 3)];
44
+ let r = laman::rigidity(4, &bars[..4]).unwrap();
45
+ assert_eq!(r.dof, 1);
46
+ let r = laman::rigidity(4, &bars).unwrap();
47
+ assert_eq!(r.dof, 0);
48
+ assert_eq!(r.redundant, vec![5]);
49
+ ```
50
+
51
+ ```python
52
+ import laman
53
+
54
+ r = laman.rigidity(4, [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (1, 3)])
55
+ assert r.dof == 0 and r.redundant == [5]
56
+ ```
57
+
58
+ ```sh
59
+ cargo add laman
60
+ pip install laman
61
+ ```
62
+
63
+ ## The name
64
+
65
+ `laman` is named after Laman's theorem (G. Laman, *On graphs and rigidity
66
+ of plane skeletal structures*, 1970), which says exactly when points
67
+ joined by rigid bars in the plane are rigid: `2n − 3` bars for `n` points,
68
+ and no subset of `k` points carrying more than `2k − 3` of them. Counting
69
+ the freedom that remains, and saying where constraints are missing or in
70
+ excess, is what this constraint solver is for. The name implies no
71
+ endorsement by Laman or his estate.
72
+
73
+ ## Licence
74
+
75
+ Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or
76
+ [MIT license](LICENSE-MIT) at your option. Unless you explicitly state
77
+ otherwise, any contribution intentionally submitted for inclusion in this
78
+ work by you, as defined in the Apache-2.0 license, shall be dual licensed
79
+ as above, without any additional terms or conditions.
80
+
laman-0.0.1/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # laman
2
+
3
+ A deterministic 2D geometric constraint solver in pure Rust, for Rust and
4
+ Python, built to be trusted.
5
+
6
+ `laman` is a library for sketchers, code-CAD tools, mechanism designers and
7
+ anything else that places points, lines, circles and arcs under geometric
8
+ and dimensional constraints. Its aim: the exact remaining degrees of
9
+ freedom of a sketch, the constraints to blame when it cannot be satisfied,
10
+ smooth interactive dragging, and the same bits on every platform, under
11
+ MIT OR Apache-2.0.
12
+
13
+ ## Status
14
+
15
+ Early. The sketch solver is being designed and built in the open
16
+ ([`SEED.md`](https://github.com/arris-labs/laman/blob/main/SEED.md) is the
17
+ charter). This release holds the combinatorial core the project is named
18
+ after: the **generic rigidity count of a bar framework** in the plane.
19
+
20
+ ```rust
21
+ // A square flexes: one internal freedom. A diagonal makes it rigid,
22
+ // and a second diagonal is redundant.
23
+ let bars = [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (1, 3)];
24
+ let r = laman::rigidity(4, &bars[..4]).unwrap();
25
+ assert_eq!(r.dof, 1);
26
+ let r = laman::rigidity(4, &bars).unwrap();
27
+ assert_eq!(r.dof, 0);
28
+ assert_eq!(r.redundant, vec![5]);
29
+ ```
30
+
31
+ ```python
32
+ import laman
33
+
34
+ r = laman.rigidity(4, [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (1, 3)])
35
+ assert r.dof == 0 and r.redundant == [5]
36
+ ```
37
+
38
+ ```sh
39
+ cargo add laman
40
+ pip install laman
41
+ ```
42
+
43
+ ## The name
44
+
45
+ `laman` is named after Laman's theorem (G. Laman, *On graphs and rigidity
46
+ of plane skeletal structures*, 1970), which says exactly when points
47
+ joined by rigid bars in the plane are rigid: `2n − 3` bars for `n` points,
48
+ and no subset of `k` points carrying more than `2k − 3` of them. Counting
49
+ the freedom that remains, and saying where constraints are missing or in
50
+ excess, is what this constraint solver is for. The name implies no
51
+ endorsement by Laman or his estate.
52
+
53
+ ## Licence
54
+
55
+ Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or
56
+ [MIT license](LICENSE-MIT) at your option. Unless you explicitly state
57
+ otherwise, any contribution intentionally submitted for inclusion in this
58
+ work by you, as defined in the Apache-2.0 license, shall be dual licensed
59
+ as above, without any additional terms or conditions.
@@ -0,0 +1,18 @@
1
+ [package]
2
+ name = "laman"
3
+ description = "A deterministic 2D geometric constraint solver in pure Rust. Early release: the generic rigidity count of bar frameworks (Laman's theorem)"
4
+ keywords = ["constraint-solver", "cad", "sketch", "geometry", "rigidity"]
5
+ categories = ["mathematics", "science", "graphics"]
6
+ version.workspace = true
7
+ edition.workspace = true
8
+ rust-version.workspace = true
9
+ license.workspace = true
10
+ repository.workspace = true
11
+ homepage.workspace = true
12
+ readme = "README.md"
13
+
14
+ [dependencies]
15
+ thiserror.workspace = true
16
+
17
+ [lints]
18
+ workspace = true
@@ -0,0 +1,59 @@
1
+ # laman
2
+
3
+ A deterministic 2D geometric constraint solver in pure Rust, for Rust and
4
+ Python, built to be trusted.
5
+
6
+ `laman` is a library for sketchers, code-CAD tools, mechanism designers and
7
+ anything else that places points, lines, circles and arcs under geometric
8
+ and dimensional constraints. Its aim: the exact remaining degrees of
9
+ freedom of a sketch, the constraints to blame when it cannot be satisfied,
10
+ smooth interactive dragging, and the same bits on every platform, under
11
+ MIT OR Apache-2.0.
12
+
13
+ ## Status
14
+
15
+ Early. The sketch solver is being designed and built in the open
16
+ ([`SEED.md`](https://github.com/arris-labs/laman/blob/main/SEED.md) is the
17
+ charter). This release holds the combinatorial core the project is named
18
+ after: the **generic rigidity count of a bar framework** in the plane.
19
+
20
+ ```rust
21
+ // A square flexes: one internal freedom. A diagonal makes it rigid,
22
+ // and a second diagonal is redundant.
23
+ let bars = [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (1, 3)];
24
+ let r = laman::rigidity(4, &bars[..4]).unwrap();
25
+ assert_eq!(r.dof, 1);
26
+ let r = laman::rigidity(4, &bars).unwrap();
27
+ assert_eq!(r.dof, 0);
28
+ assert_eq!(r.redundant, vec![5]);
29
+ ```
30
+
31
+ ```python
32
+ import laman
33
+
34
+ r = laman.rigidity(4, [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2), (1, 3)])
35
+ assert r.dof == 0 and r.redundant == [5]
36
+ ```
37
+
38
+ ```sh
39
+ cargo add laman
40
+ pip install laman
41
+ ```
42
+
43
+ ## The name
44
+
45
+ `laman` is named after Laman's theorem (G. Laman, *On graphs and rigidity
46
+ of plane skeletal structures*, 1970), which says exactly when points
47
+ joined by rigid bars in the plane are rigid: `2n − 3` bars for `n` points,
48
+ and no subset of `k` points carrying more than `2k − 3` of them. Counting
49
+ the freedom that remains, and saying where constraints are missing or in
50
+ excess, is what this constraint solver is for. The name implies no
51
+ endorsement by Laman or his estate.
52
+
53
+ ## Licence
54
+
55
+ Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE) or
56
+ [MIT license](LICENSE-MIT) at your option. Unless you explicitly state
57
+ otherwise, any contribution intentionally submitted for inclusion in this
58
+ work by you, as defined in the Apache-2.0 license, shall be dual licensed
59
+ as above, without any additional terms or conditions.
@@ -0,0 +1,29 @@
1
+ //! `laman`: a deterministic 2D geometric constraint solver in pure Rust.
2
+ //!
3
+ //! The solver (sketches of points, lines, circles and arcs, their
4
+ //! constraints, the numerical solve and its diagnostics) is being built;
5
+ //! `SEED.md` in the repository is the charter. This early release holds the
6
+ //! combinatorial core the name comes from: [`rigidity`], the generic
7
+ //! rigidity count of a bar framework in the plane (Laman's theorem), with
8
+ //! the bars that are redundant named.
9
+ //!
10
+ //! ```
11
+ //! use laman::rigidity;
12
+ //!
13
+ //! // A triangle is rigid; a fourth point on two bars makes it a rigid
14
+ //! // "diamond"; one more bar across it is redundant.
15
+ //! let bars = [(0, 1), (1, 2), (2, 0), (1, 3), (2, 3), (0, 3)];
16
+ //! let r = rigidity(4, &bars).unwrap();
17
+ //! assert_eq!(r.dof, 0);
18
+ //! assert_eq!(r.redundant, vec![5]);
19
+ //! ```
20
+ #![forbid(unsafe_code)]
21
+
22
+ mod rigidity;
23
+
24
+ pub use rigidity::{Rigidity, RigidityError, rigidity};
25
+
26
+ /// The README's Rust example runs as a doctest, so it cannot drift.
27
+ #[doc = include_str!("../../../README.md")]
28
+ #[cfg(doctest)]
29
+ struct ReadmeDoctests;
@@ -0,0 +1,246 @@
1
+ //! Generic rigidity of bar frameworks in the plane, by the (2,3) pebble game.
2
+ //!
3
+ //! Laman's theorem (G. Laman, *On graphs and rigidity of plane skeletal
4
+ //! structures*, 1970): for points in generic position, a set of bars is
5
+ //! independent exactly when every subset of `k ≥ 2` points spans at most
6
+ //! `2k − 3` of its bars. The independent sets are those of a matroid, so a
7
+ //! greedy pass over the bars in the caller's order finds a maximal
8
+ //! independent set; the bars it rejects are the redundant ones. The pass is
9
+ //! the pebble game of D. Jacobs and B. Hendrickson, *An algorithm for
10
+ //! two-dimensional rigidity percolation: the pebble game* (J. Comput.
11
+ //! Phys., 1997), implemented from the paper.
12
+ //!
13
+ //! Everything here is combinatorial: no floating point, the same answer on
14
+ //! every platform.
15
+
16
+ use std::collections::BTreeMap;
17
+
18
+ /// Free pebbles a point starts with: its two freedoms in the plane.
19
+ const PEBBLES_PER_POINT: u8 = 2;
20
+ /// Pebbles that must sit on a bar's two ends for it to be independent:
21
+ /// `l + 1` for the (2, `l` = 3) pebble game.
22
+ const PEBBLES_TO_ACCEPT: u8 = 4;
23
+
24
+ /// A bar names a point the framework does not have, or a point twice.
25
+ #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
26
+ #[non_exhaustive]
27
+ pub enum RigidityError {
28
+ /// `bars[bar]` names `point`, but the framework has only `points` points.
29
+ #[error("bar {bar} names point {point}, but the framework has {points} points")]
30
+ PointOutOfRange {
31
+ /// Index of the bar in the caller's slice.
32
+ bar: usize,
33
+ /// The point it names.
34
+ point: usize,
35
+ /// The number of points in the framework.
36
+ points: usize,
37
+ },
38
+ /// `bars[bar]` joins `point` to itself.
39
+ #[error("bar {bar} joins point {point} to itself")]
40
+ SelfLoop {
41
+ /// Index of the bar in the caller's slice.
42
+ bar: usize,
43
+ /// The point at both of its ends.
44
+ point: usize,
45
+ },
46
+ /// The freedom count `2 × points` does not fit in a `usize`.
47
+ #[error("{points} points have more freedoms than a usize holds")]
48
+ TooManyPoints {
49
+ /// The number of points asked for.
50
+ points: usize,
51
+ },
52
+ }
53
+
54
+ /// The generic rigidity of a bar framework.
55
+ ///
56
+ /// "Generic" means for points in general position: a framework this
57
+ /// reports rigid can still flex at special positions (three collinear
58
+ /// points), never the other way round.
59
+ #[derive(Debug, Clone, PartialEq, Eq)]
60
+ #[non_exhaustive]
61
+ pub struct Rigidity {
62
+ /// The number of points in the framework.
63
+ pub points: usize,
64
+ /// Indices of the bars that are independent, ascending. The first
65
+ /// maximal independent set in the caller's order.
66
+ pub independent: Vec<usize>,
67
+ /// Indices of the bars that are redundant given the bars before them,
68
+ /// ascending. Each one could be removed without the framework gaining
69
+ /// a freedom.
70
+ pub redundant: Vec<usize>,
71
+ /// Remaining internal degrees of freedom: the freedoms of the points
72
+ /// minus those the independent bars remove, minus the motions of the
73
+ /// whole framework (2 for one point, 3 for two or more).
74
+ pub dof: usize,
75
+ }
76
+
77
+ impl Rigidity {
78
+ /// Whether the framework is generically rigid (no internal freedom).
79
+ pub fn is_rigid(&self) -> bool {
80
+ self.dof == 0
81
+ }
82
+
83
+ /// Whether the framework is rigid with no redundant bar: `2n − 3` bars
84
+ /// for `n ≥ 2` points, a Laman graph.
85
+ pub fn is_minimally_rigid(&self) -> bool {
86
+ self.is_rigid() && self.redundant.is_empty()
87
+ }
88
+ }
89
+
90
+ /// The generic rigidity of `points` points joined by `bars`, each bar a
91
+ /// pair of point indices in `0..points`.
92
+ ///
93
+ /// Bars may repeat (a repeat is redundant). Points no bar names are free
94
+ /// and count toward [`Rigidity::dof`]. The result depends only on the
95
+ /// input, the order of `bars` included: of several redundant bars that
96
+ /// could be dropped, the later ones are named.
97
+ ///
98
+ /// # Errors
99
+ ///
100
+ /// [`RigidityError`] for a bar naming a point out of range or the same
101
+ /// point twice, and for a point count whose freedoms overflow a `usize`.
102
+ ///
103
+ /// # Example
104
+ ///
105
+ /// ```
106
+ /// // A square flexes: one internal freedom. Its diagonal makes it rigid.
107
+ /// let square = [(0, 1), (1, 2), (2, 3), (3, 0)];
108
+ /// assert_eq!(laman::rigidity(4, &square).unwrap().dof, 1);
109
+ /// let braced = [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)];
110
+ /// assert!(laman::rigidity(4, &braced).unwrap().is_minimally_rigid());
111
+ /// ```
112
+ pub fn rigidity(points: usize, bars: &[(usize, usize)]) -> Result<Rigidity, RigidityError> {
113
+ let freedoms = points
114
+ .checked_mul(usize::from(PEBBLES_PER_POINT))
115
+ .ok_or(RigidityError::TooManyPoints { points })?;
116
+
117
+ // The game runs over the points the bars name only, numbered in
118
+ // ascending order, so its memory follows the bars and not `points`.
119
+ let mut slot: BTreeMap<usize, usize> = BTreeMap::new();
120
+ for (bar, &(a, b)) in bars.iter().enumerate() {
121
+ for point in [a, b] {
122
+ if point >= points {
123
+ return Err(RigidityError::PointOutOfRange { bar, point, points });
124
+ }
125
+ }
126
+ if a == b {
127
+ return Err(RigidityError::SelfLoop { bar, point: a });
128
+ }
129
+ slot.insert(a, 0);
130
+ slot.insert(b, 0);
131
+ }
132
+ for (i, s) in slot.values_mut().enumerate() {
133
+ *s = i;
134
+ }
135
+
136
+ let mut game = PebbleGame::new(slot.len());
137
+ let mut independent = Vec::new();
138
+ let mut redundant = Vec::new();
139
+ for (bar, (a, b)) in bars.iter().enumerate() {
140
+ let (Some(&u), Some(&v)) = (slot.get(a), slot.get(b)) else {
141
+ // Every endpoint was given a slot above.
142
+ continue;
143
+ };
144
+ if game.insert(u, v) {
145
+ independent.push(bar);
146
+ } else {
147
+ redundant.push(bar);
148
+ }
149
+ }
150
+
151
+ let trivial = match points {
152
+ 0 => 0,
153
+ 1 => 2,
154
+ _ => 3,
155
+ };
156
+ // An independent set holds at most 2n − 3 bars, so this never saturates
157
+ // for n ≥ 2; it is written saturating so no input can make it panic.
158
+ let dof = freedoms
159
+ .saturating_sub(independent.len())
160
+ .saturating_sub(trivial);
161
+ Ok(Rigidity {
162
+ points,
163
+ independent,
164
+ redundant,
165
+ dof,
166
+ })
167
+ }
168
+
169
+ /// The (2,3) pebble game over points `0..n`.
170
+ ///
171
+ /// Each point holds up to two free pebbles; each accepted bar is covered by
172
+ /// one pebble of one of its ends and directed away from it (`out[x]` holds
173
+ /// the far ends of the bars `x` covers). Ordered containers and a fixed
174
+ /// search order keep every run identical.
175
+ struct PebbleGame {
176
+ pebbles: Vec<u8>,
177
+ out: Vec<Vec<usize>>,
178
+ }
179
+
180
+ impl PebbleGame {
181
+ fn new(n: usize) -> Self {
182
+ Self {
183
+ pebbles: vec![PEBBLES_PER_POINT; n],
184
+ out: vec![Vec::new(); n],
185
+ }
186
+ }
187
+
188
+ /// Inserts the bar `u`–`v` if it is independent of the bars accepted so
189
+ /// far; returns whether it was.
190
+ fn insert(&mut self, u: usize, v: usize) -> bool {
191
+ while self.pebbles[u] < PEBBLES_PER_POINT && self.fetch(u, v) {}
192
+ while self.pebbles[v] < PEBBLES_PER_POINT && self.fetch(v, u) {}
193
+ if self.pebbles[u] + self.pebbles[v] < PEBBLES_TO_ACCEPT {
194
+ return false;
195
+ }
196
+ self.pebbles[u] -= 1;
197
+ self.out[u].push(v);
198
+ true
199
+ }
200
+
201
+ /// Brings one free pebble to `root` along a directed path that avoids
202
+ /// `held` (whose pebbles are being kept), reversing the path's bars.
203
+ /// Returns false when no free pebble is reachable.
204
+ fn fetch(&mut self, root: usize, held: usize) -> bool {
205
+ let n = self.pebbles.len();
206
+ let mut parent: Vec<Option<usize>> = vec![None; n];
207
+ let mut seen = vec![false; n];
208
+ seen[root] = true;
209
+ seen[held] = true;
210
+ let mut stack = vec![root];
211
+ let mut found = None;
212
+ 'search: while let Some(x) = stack.pop() {
213
+ for &y in &self.out[x] {
214
+ if seen[y] {
215
+ continue;
216
+ }
217
+ seen[y] = true;
218
+ parent[y] = Some(x);
219
+ if self.pebbles[y] > 0 {
220
+ found = Some(y);
221
+ break 'search;
222
+ }
223
+ stack.push(y);
224
+ }
225
+ }
226
+ let Some(found) = found else {
227
+ return false;
228
+ };
229
+ let mut y = found;
230
+ while let Some(x) = parent[y] {
231
+ self.reverse(x, y);
232
+ y = x;
233
+ }
234
+ self.pebbles[found] -= 1;
235
+ self.pebbles[root] += 1;
236
+ true
237
+ }
238
+
239
+ /// Turns the bar `x → y` into `y → x`: `y`'s pebble covers it now.
240
+ fn reverse(&mut self, x: usize, y: usize) {
241
+ if let Some(i) = self.out[x].iter().position(|&h| h == y) {
242
+ self.out[x].remove(i);
243
+ self.out[y].push(x);
244
+ }
245
+ }
246
+ }
@@ -0,0 +1,148 @@
1
+ //! The rigidity count against hand-counted frameworks and against Laman's
2
+ //! condition checked by brute force over every subset of points.
3
+
4
+ use laman::{RigidityError, rigidity};
5
+
6
+ #[test]
7
+ fn rigidity_triangle_is_minimally_rigid() {
8
+ let r = rigidity(3, &[(0, 1), (1, 2), (2, 0)]).unwrap();
9
+ assert_eq!(r.dof, 0);
10
+ assert!(r.is_minimally_rigid());
11
+ }
12
+
13
+ #[test]
14
+ fn rigidity_square_has_one_freedom() {
15
+ let r = rigidity(4, &[(0, 1), (1, 2), (2, 3), (3, 0)]).unwrap();
16
+ assert_eq!(r.dof, 1);
17
+ assert!(r.redundant.is_empty());
18
+ }
19
+
20
+ #[test]
21
+ fn rigidity_complete_four_names_its_last_bar_redundant() {
22
+ let bars = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)];
23
+ let r = rigidity(4, &bars).unwrap();
24
+ assert_eq!(r.dof, 0);
25
+ assert_eq!(r.independent, vec![0, 1, 2, 3, 4]);
26
+ assert_eq!(r.redundant, vec![5]);
27
+ }
28
+
29
+ #[test]
30
+ fn rigidity_bowtie_hinges_at_its_shared_point() {
31
+ // Two triangles sharing point 2: rigid each, one rotation between them.
32
+ let bars = [(0, 1), (1, 2), (2, 0), (2, 3), (3, 4), (4, 2)];
33
+ let r = rigidity(5, &bars).unwrap();
34
+ assert_eq!(r.dof, 1);
35
+ assert!(r.redundant.is_empty());
36
+ }
37
+
38
+ #[test]
39
+ fn rigidity_redundancy_is_local() {
40
+ // K4 (one redundant bar) hanging off a pendant point: the excess in K4
41
+ // does not hold the pendant, which keeps one freedom.
42
+ let bars = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3), (3, 4)];
43
+ let r = rigidity(5, &bars).unwrap();
44
+ assert_eq!(r.redundant, vec![5]);
45
+ assert_eq!(r.dof, 1);
46
+ }
47
+
48
+ #[test]
49
+ fn rigidity_repeated_bar_is_redundant() {
50
+ let r = rigidity(2, &[(0, 1), (1, 0)]).unwrap();
51
+ assert_eq!(r.dof, 0);
52
+ assert_eq!(r.redundant, vec![1]);
53
+ }
54
+
55
+ #[test]
56
+ fn rigidity_counts_free_points() {
57
+ assert_eq!(rigidity(0, &[]).unwrap().dof, 0);
58
+ assert_eq!(rigidity(1, &[]).unwrap().dof, 0);
59
+ assert_eq!(rigidity(2, &[]).unwrap().dof, 1);
60
+ // A triangle and two loose points: 2 × 2 freedoms of the loose points.
61
+ let r = rigidity(5, &[(0, 1), (1, 2), (2, 0)]).unwrap();
62
+ assert_eq!(r.dof, 4);
63
+ }
64
+
65
+ #[test]
66
+ fn rigidity_refuses_bad_bars() {
67
+ assert_eq!(
68
+ rigidity(3, &[(0, 1), (1, 3)]),
69
+ Err(RigidityError::PointOutOfRange {
70
+ bar: 1,
71
+ point: 3,
72
+ points: 3
73
+ })
74
+ );
75
+ assert_eq!(
76
+ rigidity(3, &[(2, 2)]),
77
+ Err(RigidityError::SelfLoop { bar: 0, point: 2 })
78
+ );
79
+ assert_eq!(
80
+ rigidity(usize::MAX, &[]),
81
+ Err(RigidityError::TooManyPoints { points: usize::MAX })
82
+ );
83
+ }
84
+
85
+ #[test]
86
+ fn rigidity_memory_follows_the_bars() {
87
+ // A huge point count with few bars: the game runs over the named points.
88
+ let n = usize::MAX / 2;
89
+ let r = rigidity(n, &[(0, n - 1), (n - 1, 7), (7, 0)]).unwrap();
90
+ assert_eq!(r.dof, 2 * n - 3 - 3);
91
+ }
92
+
93
+ /// Whether `bars` over `n` points satisfies Laman's sparsity condition:
94
+ /// every subset of `k ≥ 2` points spans at most `2k − 3` bars.
95
+ fn laman_sparse(n: usize, bars: &[(usize, usize)]) -> bool {
96
+ (0u32..1 << n).all(|subset| {
97
+ let k = subset.count_ones() as usize;
98
+ let inside = bars
99
+ .iter()
100
+ .filter(|(a, b)| subset & (1 << a) != 0 && subset & (1 << b) != 0)
101
+ .count();
102
+ k < 2 || inside <= 2 * k - 3
103
+ })
104
+ }
105
+
106
+ /// A small deterministic generator, so the corpus is the same every run.
107
+ struct XorShift(u64);
108
+ impl XorShift {
109
+ fn below(&mut self, m: usize) -> usize {
110
+ self.0 ^= self.0 << 13;
111
+ self.0 ^= self.0 >> 7;
112
+ self.0 ^= self.0 << 17;
113
+ (self.0 % m as u64) as usize
114
+ }
115
+ }
116
+
117
+ #[test]
118
+ fn rigidity_matches_greedy_laman_sparsity() {
119
+ let mut rng = XorShift(0x5eed_1a3a_2026_1101);
120
+ for _ in 0..1000 {
121
+ let n = 2 + rng.below(6);
122
+ let m = rng.below(3 * n);
123
+ let bars: Vec<(usize, usize)> = (0..m)
124
+ .map(|_| {
125
+ let a = rng.below(n);
126
+ (a, (a + 1 + rng.below(n - 1)) % n)
127
+ })
128
+ .collect();
129
+
130
+ // The greedy basis by brute force: keep a bar when the kept set
131
+ // stays sparse with it.
132
+ let mut kept: Vec<(usize, usize)> = Vec::new();
133
+ let mut expected = Vec::new();
134
+ for (i, &bar) in bars.iter().enumerate() {
135
+ kept.push(bar);
136
+ if laman_sparse(n, &kept) {
137
+ expected.push(i);
138
+ } else {
139
+ kept.pop();
140
+ }
141
+ }
142
+
143
+ let r = rigidity(n, &bars).unwrap();
144
+ assert_eq!(r.independent, expected, "n = {n}, bars = {bars:?}");
145
+ assert_eq!(r.independent.len() + r.redundant.len(), m);
146
+ assert_eq!(r.dof, 2 * n - expected.len() - 3, "n = {n}, bars = {bars:?}");
147
+ }
148
+ }
@@ -0,0 +1,31 @@
1
+ [package]
2
+ name = "laman-py"
3
+ description = "Laman: the Python binding (PyPI `laman`), a thin layer over the crate"
4
+ # Published to PyPI as `laman`, never to crates.io (SEED.md §7).
5
+ publish = false
6
+ version.workspace = true
7
+ edition.workspace = true
8
+ rust-version.workspace = true
9
+ license.workspace = true
10
+ repository.workspace = true
11
+
12
+ [lib]
13
+ name = "laman_py"
14
+ # The cdylib is the extension module; the rlib keeps `cargo test` and clippy
15
+ # working over the workspace.
16
+ crate-type = ["cdylib", "rlib"]
17
+
18
+ [features]
19
+ # Set by maturin (`pyproject.toml`), never by a Rust build: an extension
20
+ # module must not link libpython, a test binary must.
21
+ extension-module = ["pyo3/extension-module"]
22
+
23
+ [dependencies]
24
+ laman.workspace = true
25
+
26
+ # pyo3 does not build for wasm32-unknown-unknown, so the crate is empty there.
27
+ [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
28
+ pyo3.workspace = true
29
+
30
+ [lints]
31
+ workspace = true
@@ -0,0 +1,74 @@
1
+ //! `laman-py`: the Python binding, the package `laman` on PyPI.
2
+ //!
3
+ //! A thin 1:1 layer over the `laman` crate; nothing here computes. The
4
+ //! crate is `publish = false` on crates.io and empty on
5
+ //! `wasm32-unknown-unknown`, where pyo3 does not build.
6
+ #![forbid(unsafe_code)]
7
+ #![cfg(not(target_arch = "wasm32"))]
8
+
9
+ use pyo3::create_exception;
10
+ use pyo3::exceptions::PyValueError;
11
+ use pyo3::prelude::*;
12
+
13
+ create_exception!(
14
+ laman,
15
+ RigidityError,
16
+ PyValueError,
17
+ "A bar names a point the framework does not have, or a point twice."
18
+ );
19
+
20
+ /// The generic rigidity of a bar framework (`laman::Rigidity`).
21
+ #[pyclass(frozen, module = "laman", get_all)]
22
+ pub struct Rigidity {
23
+ /// The number of points in the framework.
24
+ pub points: usize,
25
+ /// Indices of the independent bars, ascending.
26
+ pub independent: Vec<usize>,
27
+ /// Indices of the bars redundant given the bars before them, ascending.
28
+ pub redundant: Vec<usize>,
29
+ /// Remaining internal degrees of freedom.
30
+ pub dof: usize,
31
+ }
32
+
33
+ #[pymethods]
34
+ impl Rigidity {
35
+ /// Whether the framework is generically rigid (no internal freedom).
36
+ fn is_rigid(&self) -> bool {
37
+ self.dof == 0
38
+ }
39
+
40
+ /// Whether the framework is rigid with no redundant bar.
41
+ fn is_minimally_rigid(&self) -> bool {
42
+ self.dof == 0 && self.redundant.is_empty()
43
+ }
44
+
45
+ fn __repr__(&self) -> String {
46
+ format!(
47
+ "Rigidity(points={}, dof={}, independent={:?}, redundant={:?})",
48
+ self.points, self.dof, self.independent, self.redundant
49
+ )
50
+ }
51
+ }
52
+
53
+ /// The generic rigidity of `points` points joined by `bars` (pairs of
54
+ /// point indices). Raises `RigidityError` for a bar naming a point out of
55
+ /// range or the same point twice.
56
+ #[pyfunction]
57
+ fn rigidity(points: usize, bars: Vec<(usize, usize)>) -> PyResult<Rigidity> {
58
+ let r = laman::rigidity(points, &bars).map_err(|e| RigidityError::new_err(e.to_string()))?;
59
+ Ok(Rigidity {
60
+ points: r.points,
61
+ independent: r.independent,
62
+ redundant: r.redundant,
63
+ dof: r.dof,
64
+ })
65
+ }
66
+
67
+ /// The extension module `laman._laman`, re-exported by `laman/__init__.py`.
68
+ #[pymodule]
69
+ fn _laman(m: &Bound<'_, PyModule>) -> PyResult<()> {
70
+ m.add_function(wrap_pyfunction!(rigidity, m)?)?;
71
+ m.add_class::<Rigidity>()?;
72
+ m.add("RigidityError", m.py().get_type::<RigidityError>())?;
73
+ Ok(())
74
+ }
@@ -0,0 +1,30 @@
1
+ """The binding answers as the crate does; the counts themselves are tested in Rust."""
2
+
3
+ import pytest
4
+
5
+ import laman
6
+
7
+
8
+ def test_triangle_is_minimally_rigid() -> None:
9
+ r = laman.rigidity(3, [(0, 1), (1, 2), (2, 0)])
10
+ assert r.dof == 0
11
+ assert r.is_minimally_rigid()
12
+
13
+
14
+ def test_complete_four_names_its_last_bar() -> None:
15
+ bars = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
16
+ r = laman.rigidity(4, bars)
17
+ assert r.is_rigid()
18
+ assert r.independent == [0, 1, 2, 3, 4]
19
+ assert r.redundant == [5]
20
+
21
+
22
+ def test_square_flexes() -> None:
23
+ assert laman.rigidity(4, [(0, 1), (1, 2), (2, 3), (3, 0)]).dof == 1
24
+
25
+
26
+ def test_bad_bar_raises() -> None:
27
+ with pytest.raises(laman.RigidityError, match="bar 1 names point 3"):
28
+ laman.rigidity(3, [(0, 1), (1, 3)])
29
+ with pytest.raises(ValueError):
30
+ laman.rigidity(3, [(2, 2)])
@@ -0,0 +1,8 @@
1
+ """The typed stubs match the extension module (mypy.stubtest) and type-check strictly."""
2
+
3
+ import subprocess
4
+ import sys
5
+
6
+
7
+ def test_stubtest() -> None:
8
+ subprocess.run([sys.executable, "-m", "mypy.stubtest", "laman"], check=True)
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["maturin>=1.7,<2"]
3
+ build-backend = "maturin"
4
+
5
+ [project]
6
+ name = "laman"
7
+ # maturin reads the version from Cargo.toml, which inherits the workspace's.
8
+ dynamic = ["version"]
9
+ description = "A deterministic 2D geometric constraint solver in pure Rust. Early release: the generic rigidity count of bar frameworks (Laman's theorem)"
10
+ readme = "README.md"
11
+ requires-python = ">=3.10"
12
+ license = { text = "MIT OR Apache-2.0" }
13
+ keywords = ["constraint solver", "cad", "sketch", "geometry", "rigidity"]
14
+ classifiers = [
15
+ "Development Status :: 2 - Pre-Alpha",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Science/Research",
18
+ "Programming Language :: Rust",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering :: Mathematics",
21
+ "Typing :: Typed",
22
+ ]
23
+
24
+ [project.urls]
25
+ Repository = "https://github.com/arris-labs/laman"
26
+
27
+ [project.optional-dependencies]
28
+ test = ["pytest>=8", "mypy>=1.18"]
29
+
30
+ [tool.maturin]
31
+ module-name = "laman._laman"
32
+ features = ["extension-module"]
33
+ manifest-path = "crates/laman-py/Cargo.toml"
34
+ python-source = "python"
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,9 @@
1
+ """Laman: a deterministic 2D geometric constraint solver. A thin layer over the Rust crate.
2
+
3
+ This early release holds the generic rigidity count of bar frameworks
4
+ (Laman's theorem); the sketch solver is being built.
5
+ """
6
+
7
+ from ._laman import Rigidity, RigidityError, rigidity
8
+
9
+ __all__ = ["Rigidity", "RigidityError", "rigidity"]
@@ -0,0 +1,25 @@
1
+ from collections.abc import Sequence
2
+ from typing import final
3
+
4
+ __all__ = ["Rigidity", "RigidityError", "rigidity"]
5
+
6
+ class RigidityError(ValueError):
7
+ """A bar names a point the framework does not have, or a point twice."""
8
+
9
+ @final
10
+ class Rigidity:
11
+ """The generic rigidity of a bar framework."""
12
+
13
+ @property
14
+ def points(self) -> int: ...
15
+ @property
16
+ def independent(self) -> list[int]: ...
17
+ @property
18
+ def redundant(self) -> list[int]: ...
19
+ @property
20
+ def dof(self) -> int: ...
21
+ def is_rigid(self) -> bool: ...
22
+ def is_minimally_rigid(self) -> bool: ...
23
+
24
+ def rigidity(points: int, bars: Sequence[tuple[int, int]]) -> Rigidity:
25
+ """The generic rigidity of `points` points joined by `bars`."""
File without changes