clean-similar-curves
Dedup the currently selected Rhino curves. SelDup fails when coincident curves
differ in object type (LINE vs NURBS vs ARC) — this skill is type-agnostic.
The logic lives in two scripts next to this file so the validated code runs
byte-identical every time (no re-typing, no model drift): dedup_dryrun.py
(non-destructive) and dedup_delete.py (destructive). You send Rhino a tiny
one-line loader that reads+executes the file — the script body never has to be
re-emitted.
Procedure (do in order)
- Resolve the script paths. Glob
**/clean-similar-curves/dedup_dryrun.py
and **/clean-similar-curves/dedup_delete.py to get their absolute paths on
this machine. (Do NOT hardcode a path — it differs per machine/user.)
- Dry run (non-destructive). Call
mcp__rhino__execute_rhinoscript_python_code
with just the loader:exec(open(r"<ABS_PATH>\dedup_dryrun.py").read())
It diagnoses, clusters, and stashes delete-id lists in sc.sticky. Deletes nothing.
- Read the report. It prints: doc tolerance + units, nearest-neighbor distance
histogram (diagnoses exact-dup vs coordinate-drift), and TWO tiers —
Tier A
delete_safe (originals fully covered by a kept survivor; deleting
loses nothing) and Tier B delete_split + create (overlap trims:
delete the overlapping sources, create trimmed segments so the covered span is
unchanged). Also reports curved partials left untouched.
- Confirm scope with the user before applying (destructive). Present:
- Tier A (safe deletes) → always safe; no geometry created, no holes.
- Tier A+B (split overlaps) → resolves genuine partial overlaps by trimming
(cut at the overlap boundary, keep ONE copy of the shared part). Abutting
curves and standalone curves are left whole; split points are preserved.
Use AskUserQuestion: {safe only} / {safe + split} / {stop}.
- Apply by sending the delete loader with KEY set:
KEY="exact"; exec(open(r"<ABS_PATH>\dedup_delete.py").read())
Then, only if the user approved the split tier:KEY="split"; exec(open(r"<ABS_PATH>\dedup_delete.py").read())
KEY="split" CREATES trimmed segments (inheriting each donor's layer/color) then
deletes the overlapping originals. New segments are added to the selection.
- Report survivors (note created-count separately). Offer to keep the selection
for visual verify, and offer to save/update the
rhino-curve-dedup-method memory.
Fallback: if Rhino can't read the file path (e.g. permission/encoding), open
the .py with the Read tool and paste its full contents into the MCP code arg
instead of the loader. Same result, more tokens.
Why SelDup misses them (root cause)
SelDup needs identical geometry structure. A LINE and a degree-2 NURBS on the
same path are different structures → not matched. v2 detects coincidence by
sampling points and testing containment (Curve.ClosestPoint), which is
type-agnostic and — unlike GetDistancesBetweenCurves — correctly sees stick-out.
Critical gotchas (baked into dedup_dryrun.py — do not "fix" them out)
- DO NOT use
GetDistancesBetweenCurves maxDist to detect containment. It
returns maxDistance == 0 for GENUINE partial overlaps too (two curves sharing a
middle but each sticking out), not just for full containment — it measures only the
shared region and ignores the overhang. The v1 skill used md<=tol and deleted the
sticking-out curves → holes ("빵꾸"). v2 uses covered() = sample points along the
shorter curve, each must be within tol of the longer via ClosestPoint (which clamps
to the longer's domain, so an overhang endpoint → distance > tol → correctly rejected).
GetDistancesBetweenCurves is kept ONLY for the diagnostic histogram.
- Arrangement split at overlap boundaries (not min-cover). Straight collinear curves
that overlap are clustered, projected to 1-D intervals, and resolved by
arrange_plan:
split at EVERY overlap boundary and keep ONE copy of each shared piece, e.g.
[0-10]+[5-15] -> [0-5]+[5-10]+[10-15] (3 chunks — user's stated intent). Fully-contained
fragments (a shorter curve inside a longer) are DELETED, NOT split-through — the long
curve stays whole ([0-20]+[5-10] -> [0-20], fragment gone). Abutting / standalone actives
(no interior breakpoint) are kept whole. Guarantees: covered span unchanged (no holes),
zero overlaps, split points at real meeting points only.
- Survivor constraint. A fragment is
delete_safe only if a KEPT-WHOLE survivor
covers it; otherwise it goes to delete_split and its cover is realized as a create.
Never delete a fragment whose only cover is itself being deleted.
- Curved (non-linear) curves are handled conservatively: exact dups (mutual
containment + equal length) are removed; curved partials are LEFT UNTOUCHED (not
split) and reported — splitting general curves at overlap is not implemented.
- Diagnose, don't guess tolerance. The nearest-neighbor histogram shows whether
dups are sub-micron exact (type issue) or a small nonzero band (coordinate drift,
common when coords are far from origin). Raise
TOL_DIST/TOL_LEN in the .py if drift.
- Keep priority keeps native LINE/ARC over NURBS for non-linear exact-dup clusters.
Parameters
Edit the # ---- params ---- block at the top of dedup_dryrun.py:
TOL_DIST, TOL_LEN (None → doc tolerance), MARGIN (bbox candidate margin),
ANG_TOL (collinearity radians), SAMPLES (containment sample count),
KEEP_PRIORITY (which type to keep per non-linear cluster).
Reference
- v1 validated 2026-06-30: 181 curves → 56 exact + 30 covered fragments removed → 95.
- v2 (2026-07-01): rewrote overlap handling after
md<=tol was found to delete
genuine partial overlaps → holes. Now containment-test + arrangement split.
Split policy = split at every overlap boundary (partial [0-10]+[5-15] → 3 chunks),
contained fragments deleted (long kept whole), abut/standalone kept whole. Verified
by in-doc synthetic test with EXACT expected segment lists (partial→3 / chain→5 /
subset / exact / abut / standalone) — all pass. Scope this pass: CURVES only, collinear
overlaps only (no cross/T-junction shatter, no solids — deferred). See memory
rhino-curve-dedup-method.
Not yet implemented (deferred, by user decision 2026-07-01)
- Non-curve types (polysurface / brep / mesh / point / block) dedup — curves only for now.
- Cross / T-junction shatter ("ㅗ": splitting a curve where a different-direction curve
crosses or ends on its interior). User chose "overlap boundaries only" this pass; full
planar-arrangement shatter via
Intersect.CurveCurve + Split + JoinCurves is the
next step if wanted. A report-only detector now exists next to this file:
tjunction_report.py (glob **/clean-similar-curves/tjunction_report.py) — it flags
endpoints landing on another curve's interior (verified: detects the T, ignores end-to-end
joins). Run it for a count; the v2 APPLY (Curve.Split at the param + re-join) is still deferred.
O(n²) with a bbox prefilter — run on a subset for huge selections.
- Post-shatter smart join of degree-2 passthrough nodes.
1---2name: clean-similar-curves3description: Remove duplicate / overlapping Rhino curves that SelDup misses — curves tracing the same path but stored as different object types (LineCurve vs NurbsCurve vs ArcCurve), plus shorter sub-segments lying on longer ones. Runs a non-destructive diagnose+dry-run via Rhino MCP, reports exact-dup and partial-overlap counts, confirms scope with the user, then deletes. Trigger when the user wants to clean / dedup / remove duplicate or similar curves or lines on the current Rhino selection. Also fire on natural-language requests in Korean or English, e.g. "중복 곡선 정리해줘", "겹친 선/커브 지워줘", "비슷한 선 정리", "중복선 삭제", "clean similar curves", "remove duplicate lines/curves", "dedup curves".4---56# clean-similar-curves78Dedup the **currently selected** Rhino curves. SelDup fails when coincident curves9differ in object *type* (LINE vs NURBS vs ARC) — this skill is type-agnostic.1011The logic lives in two scripts **next to this file** so the validated code runs12byte-identical every time (no re-typing, no model drift): `dedup_dryrun.py`13(non-destructive) and `dedup_delete.py` (destructive). You send Rhino a tiny14one-line loader that reads+executes the file — the script body never has to be15re-emitted.1617## Procedure (do in order)18191. **Resolve the script paths.** Glob `**/clean-similar-curves/dedup_dryrun.py`20 and `**/clean-similar-curves/dedup_delete.py` to get their absolute paths on21 this machine. (Do NOT hardcode a path — it differs per machine/user.)222. **Dry run (non-destructive).** Call `mcp__rhino__execute_rhinoscript_python_code`23 with just the loader:24 ```python25 exec(open(r"<ABS_PATH>\dedup_dryrun.py").read())26 ```27 It diagnoses, clusters, and stashes delete-id lists in `sc.sticky`. Deletes nothing.283. **Read the report.** It prints: doc tolerance + units, nearest-neighbor distance29 histogram (diagnoses exact-dup vs coordinate-drift), and TWO tiers —30 **Tier A `delete_safe`** (originals fully covered by a kept survivor; deleting31 loses nothing) and **Tier B `delete_split`** + **`create`** (overlap trims:32 delete the overlapping sources, create trimmed segments so the covered span is33 unchanged). Also reports curved partials left untouched.344. **Confirm scope with the user** before applying (destructive). Present:35 - Tier A (safe deletes) → always safe; no geometry created, no holes.36 - Tier A+B (split overlaps) → resolves genuine partial overlaps by trimming37 (cut at the overlap boundary, keep ONE copy of the shared part). Abutting38 curves and standalone curves are left whole; split points are preserved.39 Use AskUserQuestion: {safe only} / {safe + split} / {stop}.405. **Apply** by sending the delete loader with KEY set:41 ```python42 KEY="exact"; exec(open(r"<ABS_PATH>\dedup_delete.py").read())43 ```44 Then, only if the user approved the split tier:45 ```python46 KEY="split"; exec(open(r"<ABS_PATH>\dedup_delete.py").read())47 ```48 `KEY="split"` CREATES trimmed segments (inheriting each donor's layer/color) then49 deletes the overlapping originals. New segments are added to the selection.506. **Report** survivors (note created-count separately). Offer to keep the selection51 for visual verify, and offer to save/update the `rhino-curve-dedup-method` memory.5253**Fallback:** if Rhino can't read the file path (e.g. permission/encoding), open54the `.py` with the Read tool and paste its full contents into the MCP `code` arg55instead of the loader. Same result, more tokens.5657## Why SelDup misses them (root cause)58SelDup needs identical geometry *structure*. A LINE and a degree-2 NURBS on the59same path are different structures → not matched. v2 detects coincidence by60**sampling points and testing containment** (`Curve.ClosestPoint`), which is61type-agnostic and — unlike `GetDistancesBetweenCurves` — correctly sees stick-out.6263## Critical gotchas (baked into dedup_dryrun.py — do not "fix" them out)64- **DO NOT use `GetDistancesBetweenCurves` maxDist to detect containment.** It65 returns `maxDistance == 0` for GENUINE partial overlaps too (two curves sharing a66 middle but each sticking out), not just for full containment — it measures only the67 shared region and ignores the overhang. The v1 skill used `md<=tol` and deleted the68 sticking-out curves → **holes ("빵꾸")**. v2 uses `covered()` = sample points along the69 shorter curve, each must be within tol of the longer via `ClosestPoint` (which clamps70 to the longer's domain, so an overhang endpoint → distance > tol → correctly rejected).71 `GetDistancesBetweenCurves` is kept ONLY for the diagnostic histogram.72- **Arrangement split at overlap boundaries (not min-cover).** Straight collinear curves73 that overlap are clustered, projected to 1-D intervals, and resolved by `arrange_plan`:74 split at EVERY overlap boundary and keep ONE copy of each shared piece, e.g.75 `[0-10]+[5-15] -> [0-5]+[5-10]+[10-15]` (3 chunks — user's stated intent). Fully-contained76 fragments (a shorter curve inside a longer) are DELETED, NOT split-through — the long77 curve stays whole (`[0-20]+[5-10] -> [0-20]`, fragment gone). Abutting / standalone actives78 (no interior breakpoint) are kept whole. Guarantees: covered span unchanged (no holes),79 zero overlaps, split points at real meeting points only.80- **Survivor constraint.** A fragment is `delete_safe` only if a KEPT-WHOLE survivor81 covers it; otherwise it goes to `delete_split` and its cover is realized as a `create`.82 Never delete a fragment whose only cover is itself being deleted.83- **Curved (non-linear) curves** are handled conservatively: exact dups (mutual84 containment + equal length) are removed; curved *partials* are LEFT UNTOUCHED (not85 split) and reported — splitting general curves at overlap is not implemented.86- **Diagnose, don't guess tolerance.** The nearest-neighbor histogram shows whether87 dups are sub-micron exact (type issue) or a small nonzero band (coordinate drift,88 common when coords are far from origin). Raise `TOL_DIST`/`TOL_LEN` in the .py if drift.89- **Keep priority** keeps native LINE/ARC over NURBS for non-linear exact-dup clusters.9091## Parameters92Edit the `# ---- params ----` block at the top of `dedup_dryrun.py`:93`TOL_DIST`, `TOL_LEN` (None → doc tolerance), `MARGIN` (bbox candidate margin),94`ANG_TOL` (collinearity radians), `SAMPLES` (containment sample count),95`KEEP_PRIORITY` (which type to keep per non-linear cluster).9697## Reference98- v1 validated 2026-06-30: 181 curves → 56 exact + 30 covered fragments removed → 95.99- **v2 (2026-07-01):** rewrote overlap handling after `md<=tol` was found to delete100 genuine partial overlaps → holes. Now containment-test + arrangement split.101 Split policy = split at every overlap boundary (partial `[0-10]+[5-15]` → 3 chunks),102 contained fragments deleted (long kept whole), abut/standalone kept whole. Verified103 by in-doc synthetic test with EXACT expected segment lists (partial→3 / chain→5 /104 subset / exact / abut / standalone) — all pass. Scope this pass: CURVES only, collinear105 overlaps only (no cross/T-junction shatter, no solids — deferred). See memory106 `rhino-curve-dedup-method`.107108## Not yet implemented (deferred, by user decision 2026-07-01)109- **Non-curve types** (polysurface / brep / mesh / point / block) dedup — curves only for now.110- **Cross / T-junction shatter** ("ㅗ": splitting a curve where a different-direction curve111 crosses or ends on its interior). User chose "overlap boundaries only" this pass; full112 planar-arrangement shatter via `Intersect.CurveCurve` + `Split` + `JoinCurves` is the113 next step if wanted. **A report-only detector now exists** next to this file:114 `tjunction_report.py` (glob `**/clean-similar-curves/tjunction_report.py`) — it flags115 endpoints landing on another curve's interior (verified: detects the T, ignores end-to-end116 joins). Run it for a count; the v2 APPLY (Curve.Split at the param + re-join) is still deferred.117 O(n²) with a bbox prefilter — run on a subset for huge selections.118- **Post-shatter smart join** of degree-2 passthrough nodes.