# Clean Similar Curves

> 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".

- Skill: `hongikarchi/clean-similar-curves` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add hongikarchi/clean-similar-curves`
- Raw SKILL.md: https://api.skillmd.com/api/skills/hongikarchi/clean-similar-curves/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: hongikarchi (https://skillmd.com/u/hongikarchi)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/hongikarchi/clean-similar-curves

---


# 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)

1. **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.)
2. **Dry run (non-destructive).** Call `mcp__rhino__execute_rhinoscript_python_code`
   with just the loader:
   ```python
   exec(open(r"<ABS_PATH>\dedup_dryrun.py").read())
   ```
   It diagnoses, clusters, and stashes delete-id lists in `sc.sticky`. Deletes nothing.
3. **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.
4. **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}.
5. **Apply** by sending the delete loader with KEY set:
   ```python
   KEY="exact"; exec(open(r"<ABS_PATH>\dedup_delete.py").read())
   ```
   Then, only if the user approved the split tier:
   ```python
   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.
6. **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.

