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 +211 -0
- laman-0.0.1/Cargo.toml +39 -0
- laman-0.0.1/PKG-INFO +80 -0
- laman-0.0.1/README.md +59 -0
- laman-0.0.1/crates/laman/Cargo.toml +18 -0
- laman-0.0.1/crates/laman/README.md +59 -0
- laman-0.0.1/crates/laman/src/lib.rs +29 -0
- laman-0.0.1/crates/laman/src/rigidity.rs +246 -0
- laman-0.0.1/crates/laman/tests/rigidity.rs +148 -0
- laman-0.0.1/crates/laman-py/Cargo.toml +31 -0
- laman-0.0.1/crates/laman-py/src/lib.rs +74 -0
- laman-0.0.1/crates/laman-py/tests/test_rigidity.py +30 -0
- laman-0.0.1/crates/laman-py/tests/test_stubs.py +8 -0
- laman-0.0.1/pyproject.toml +37 -0
- laman-0.0.1/python/laman/__init__.py +9 -0
- laman-0.0.1/python/laman/_laman.pyi +25 -0
- laman-0.0.1/python/laman/py.typed +0 -0
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,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
|