All findings · Lucene finding · LFV-795 · tessellator-straight-angle-bridge-copy-rejects-disjoint-holes

Tessellator rejects a valid polygon whose holes touch nothing, depending on where the outer ring's vertex list starts

Since 8.2.0, XYShape/LatLonShape.createIndexableFields can reject a valid polygon whose two holes touch nothing, depending on which vertex the outer ring is listed from. A bridge that continues an outer edge straight on leaves a 180-degree corner that isLocallyInside never accepts, so the next hole misses its only correct bridge vertex.

Summary

Priority
Low, rank 401Since 8.2.0, a valid polygon whose holes touch nothing can be rejected loudly with 'Unable to Tessellate shape' for some ring starts, and no wrong triangles are indexed; it is older and reached by more inputs than the narrow 10.x crash regressions LFV-762 and LFV-755, so it heads the loud Tessellator crash group below the silent rows.
Impact
crash
Since
8.2.0 (LUCENE-8775, 2019)introduced 2019-06-06 · about 7 years
Affected
8.2.0 to 10.6.0 and main (58 releases run)
Status
documented
Found by
bounded translation validation on integer grids; confirmed by a public-API test

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

How Lucene cuts a polygon with holes into triangles

  1. LatLonShape and XYShape index a polygon as triangles, computed by Tessellator.tessellate (ear clipping, inspired by the earcut library).
  2. Holes are removed first, left-most hole first: each hole's left-most vertex is joined to a vertex of the outer ring by a two-way cut, a bridge. Both ends of a bridge then appear twice in the ring, once on each side of the cut.
  3. To pick the outer vertex, a ray goes left from the hole vertex to the nearest outer edge. Another vertex may replace that choice only if the hole vertex lies inside its corner (isLocallyInside).
Four boxes connected by arrows show how Lucene indexes a polygon with holes: the polygon, its holes bridged into one ring (marked in red as the step where a bridge can end at the wrong copy of a vertex), the ring cut into triangles by Tessellator.tessellate, and one indexed field per triangle; below, an expected panel shows the 12 triangles covering area 9 for any start vertex and an actual panel shows the outer ring started at (5,1) or (0,0) ending in IllegalArgumentException.
Lucene turns a polygon into triangles by first bridging each hole into one ring, and this bug picks the wrong copy of a bridge end in that step. Generated with AI, checked against the code.
The example on main 5adff78bb168: clockwise outer ring (4,5) (5,1) (0,0) (1,1) with counter-clockwise holes B (2,2) (4,3) (3,3) and A (3,2) (3,1) (4,1). Hole B is bridged from (2,2) to (1,1) along the line through (0,0), so (1,1) has two copies and only the copy with a straight corner faces hole A. Listed from (4,5) or (1,1) the ring gives 12 triangles of area 9; listed from (5,1) or (0,0) it throws Unable to Tessellate shape.
The example polygon: hole B's bridge continues the outer edge (0,0)-(1,1), so (1,1) appears twice in the ring; hole A's ray lands on it, and the ring's listing decides which copy it gets. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build an XYPolygon with the clockwise outer ring (5,1) (0,0) (1,1) (4,5) and two small counter-clockwise triangle holes, A (3,2) (3,1) (4,1) and B (2,2) (4,3) (3,3), strictly inside and touching nothing.
  2. You expect XYShape.createIndexableFields to return 12 triangles with a total area of 9, as it does when the same ring is listed from (4,5) or (1,1).
  3. Instead it throws IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected. Listed from (0,0) it fails too, and so do LatLonShape and the doc-values fields: the document cannot be indexed.
  4. Why: hole B is joined first, by a bridge from (2,2) to (1,1) that continues the outer edge (0,0)-(1,1) in a straight line. The ring now passes through (1,1) twice, and only the copy with the straight 180-degree corner faces hole A.
  5. Hole A's leftward ray from (3,1) lands on (1,1) and keeps whichever copy comes first from the ring's start. isLocallyInside returns false for every straight corner (area == 0), so a wrong pick is never corrected: the bridge leaves (1,1) outside that copy's corner, the ring overlaps itself at (1,1) and triangulation fails.

Who is affected

Anyone indexing polygons with two or more holes through LatLonShape or XYShape (indexed fields or doc values) since 8.2.0, with default settings and either checkSelfIntersections value. Loud: the document is refused and nothing wrong is indexed. It needs exact collinearity, which grid-snapped or rounded coordinates can produce; generic floating-point data rarely does.

Mechanism, terms and numbers615 words

A valid polygon with two holes that lie strictly inside it and touch nothing is indexed or rejected by XYShape/LatLonShape.createIndexableFields depending only on which vertex its outer ring is listed from: of the four listings of the ring (4,5) (5,1) (0,0) (1,1), two give 12 triangles of exact area 9 and two throw "Unable to Tessellate shape". The first hole's bridge continues an outer edge in a straight line, so the outer vertex (1,1) appears twice in the ring, and only its copy with a straight corner faces the second hole. Tessellator.isLocallyInside returns false for every straight corner, so fetchHoleBridge cannot move the second hole's bridge to that copy when its ray found the other one first. A regression in 8.2.0 (LUCENE-8775); 8.1.1 accepts every listing.

The example

y=5   .   .   .   .   O   .        O  outer ring, clockwise: (4,5) (5,1) (0,0) (1,1), area 10
y=4   .   .   .   .   .   .        B  hole B, counter-clockwise: (2,2) (4,3) (3,3)
y=3   .   .   .   B   B   .        A  hole A, counter-clockwise: (3,2) (3,1) (4,1)
y=2   .   .   B   A   .   .
y=1   .   O   .   A   A   O        expected area 10 - 0.5 - 0.5 = 9
y=0   O   .   .   .   .   .
     x=0 x=1 x=2 x=3 x=4 x=5
outer ring listed fromresult on main (either hole order)
(4,5)12 triangles, area 9.0
(5,1)IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected.
(0,0)the same exception
(1,1)12 triangles, area 9.0

The five polygon entry points that were run all fail the same way for the listing from (5,1): XYShape.createIndexableFields with either checkSelfIntersections value, XYShape.createDocValueField, LatLonShape.createIndexableFields and LatLonShape.createDocValueField (lat = y, lon = x); the checkSelfIntersections overloads not run make the same Tessellator.tessellate call (XYShape.java:100,112, LatLonShape.java:102,114). For the accepted listings, a point inside each hole does not match an INTERSECTS point query and a point in the polygon does.

The mechanism (main 5adff78bb168, Tessellator.java; confirmed by a print-only trace)

  1. Holes are joined one at a time, sorted by their left-most vertex (:310-323, :325). For each, fetchHoleBridge (:536) picks an outer vertex and splitPolygon (:399, :1175-1199) cuts a two-way bridge, duplicating both ends (:1176-1177).
  2. Hole B goes first (left-most vertex (2,2)) and is bridged to (1,1). (0,0), (1,1) and (2,2) are collinear, so the bridge continues the outer edge (0,0)-(1,1). The ring now holds two copies of (1,1): one between (4,5) and (2,2), whose corner does not contain hole A, and one between (2,2) and (0,0), whose corner is a straight angle; only that copy faces hole A.
  3. Hole A's bridge vertex is (3,1) (its left-most, lowest vertex; fetchLeftmost tie-break :600-601). Step 1 of fetchHoleBridge (:542-560) casts a ray to the left and keeps the nearest hit; on a tie, the first segment in ring order from the start node (x > qx is strict, :549). The ray hits exactly (1,1), so the ring's start decides which copy it keeps (:555). Trace: listings from (4,5) and (1,1) keep the straight copy; listings from (5,1) and (0,0) keep the other one.
  4. Step 2 (:568-591) replaces the step-1 vertex only with one that passes isLocallyInside(p, holeNode) (:584). isLocallyInside (:1251-1268) starts with if (area == 0) { // parallel return false; } (:1255-1257). Trace: the wrong copy is rejected (corner area -1.0, correctly), and the straight copy is rejected too (area 0.0), so the unchecked step-1 vertex stays.
  5. The bridge from the wrong copy runs outside that copy's corner, ear clipping finds no ear and no valid split, and earcutLinkedList throws (:681-688).
Exposure and likelihood184 words
  • Who is hit: applications that index polygons with two or more holes through LatLonShape or XYShape (createIndexableFields and createDocValueField; LatLonShape.java:102,114, XYShape.java:100,112), with the default codec and IndexWriterConfig, either checkSelfIntersections value. One 10-vertex polygon is enough; no scale.
  • Effect: loud. The document's polygon is refused with an IllegalArgumentException; no wrong triangles and no wrong query result were seen. The simplest caller-side workaround is to list the ring from another vertex.
  • Trigger: one hole's bridge must continue an outer edge exactly (its left-most vertex on that edge's line through the bridge end), and a later hole's leftward ray must pass exactly through that bridge end; then the ring's listing decides (2 of 4 listings in the example).
  • Others meet this geometry: five small integer test polygons with this kind of geometry (outer rings of 4 to 11 vertices, coordinates between -9 and 9), published independently by others against earcut (#219, #221), are rejected by Lucene main in 18 of 74 listing/hole-order configurations, by 8.2.0 in 10 and by 8.1.1 in none.
  • Real-world frequency was not measured.

Minimal repro

One JUnit test on the public API: it builds the polygon with the outer ring listed from (5,1) and calls XYShape.createIndexableFields. No index and nothing random in the test body. Copy it into lucene/core/src/test/org/apache/lucene/document/ and run it with gradle.

TestTessellatorDisjointHolesCollinearBridge.java (one test, public API)35 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.document;

import org.apache.lucene.geo.XYPolygon;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestTessellatorDisjointHolesCollinearBridge extends LuceneTestCase {

  public void testPolygonWithDisjointHolesIsIndexed() {
    // two counter-clockwise holes, strictly inside the outer ring and touching nothing
    XYPolygon holeA = new XYPolygon(new float[] {3, 3, 4, 3}, new float[] {2, 1, 1, 2});
    XYPolygon holeB = new XYPolygon(new float[] {2, 4, 3, 2}, new float[] {2, 3, 3, 2});
    // clockwise outer ring (5,1) (0,0) (1,1) (4,5); the same ring listed from (4,5) is accepted
    XYPolygon polygon =
        new XYPolygon(new float[] {5, 0, 1, 4, 5}, new float[] {1, 0, 1, 5, 1}, holeA, holeB);
    // on main this throws IllegalArgumentException: Unable to Tessellate shape
    Field[] fields = XYShape.createIndexableFields("shape", polygon);
    assertTrue(fields.length > 0);
  }
}
Command
./gradlew :lucene:core:test --tests org.apache.lucene.document.TestTessellatorDisjointHolesCollinearBridge -Ptests.seed=DEADBEEF
Expected
createIndexableFields returns the polygon's 12 triangles (total area 9), as for the same ring listed from (4,5) or (1,1).
Observed
IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected. from Tessellator.earcutLinkedList, with assertions on and off.

All files of this repro: repro/LFV-795/

More files, environment, full output231 words

One test method, public API only (XYPolygon, XYShape.createIndexableFields); the seed only drives the framework's per-class settings (seeds DEADBEEF and 5EED1234 fail the same way). :lucene:core:check -x test passes with the file in place, and the test doubles as the regression test.

Left out on purpose: the other three listings (from (0,0) it fails too; from (4,5) and (1,1) it passes), LatLonShape, checkSelfIntersections=true, the doc-values entry points (all fail the same way) and the point queries of the accepted listings.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT); also the 10.6.0 and 9.12.3 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64
Command (environment)
cp $REPRO/TestTessellatorDisjointHolesCollinearBridge.java $LUCENE/lucene/core/src/test/org/apache/lucene/document/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.document.TestTessellatorDisjointHolesCollinearBridge -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)

Observed output

# gradle, main 5adff78bb168, -Ptests.seed=DEADBEEF (assertions on; -Ptests.asserts=false gives the same)
TestTessellatorDisjointHolesCollinearBridge > testPolygonWithDisjointHolesIsIndexed FAILED
    java.lang.IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected.
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:72C1CB05BBA48B47]:0)
        at org.apache.lucene.geo.Tessellator.earcutLinkedList(Tessellator.java:687)
        at org.apache.lucene.geo.Tessellator.splitEarcut(Tessellator.java:936)
        at org.apache.lucene.geo.Tessellator.earcutLinkedList(Tessellator.java:683)
        at org.apache.lucene.geo.Tessellator.splitEarcut(Tessellator.java:938)
        at org.apache.lucene.geo.Tessellator.earcutLinkedList(Tessellator.java:683)
        at org.apache.lucene.geo.Tessellator.tessellate(Tessellator.java:202)
        at org.apache.lucene.geo.Tessellator.tessellate(Tessellator.java:152)
        at org.apache.lucene.document.XYShape.createIndexableFields(XYShape.java:100)
        at org.apache.lucene.document.XYShape.createIndexableFields(XYShape.java:84)
        at org.apache.lucene.document.TestTessellatorDisjointHolesCollinearBridge.testPolygonWithDisjointHolesIsIndexed(TestTessellatorDisjointHolesCollinearBridge.java:32)
:lucene:core:test (FAILURE): 1 test, 1  failure

# all four listings of the outer ring, both hole orders (Tessellator.tessellate, main)
start (4,5): 12 triangles, area 9.0      start (5,1): Unable to Tessellate shape
start (0,0): Unable to Tessellate shape  start (1,1): 12 triangles, area 9.0

# with the recommended fix: :lucene:core:test (SUCCESS): 1 test; every start 12 triangles, area 9.0

Why it looks unintended

  • The polygon meets every javadoc requirement: no self-intersections, holes inside, holes touching at most at one vertex, a non-zero area, and no tiny values such as E-200.
  • Re-listed from (4,5) or (1,1) the same ring is accepted; 8.1.1 and earcut accept every listing.
  • The area == 0 branch came with LUCENE-8775, a shared-vertex fix; nothing documents straight corners as unsupported.
The full argument222 words
  • Contract: the class javadoc's notes (Tessellator.java:40-50) list "Requires valid polygons:" with the items "No self intersections", "Holes must be inside the polygon", "Holes may only touch at one vertex", "Polygon must have an area (e.g., no "line" boxes)" and "sensitive to overflow (e.g, subatomic values such as E-200 can cause unexpected behavior)"; small integer coordinates are far from the last one. Polygon.java:31-32: "Coordinates must be in clockwise order, except for holes. Holes must be in counter-clockwise order." The polygon meets all of them without using the "touch at one vertex" allowance, and no contract lets the result depend on where a ring's list starts.
  • History: before LUCENE-8775 a straight corner fell into a strict half-plane test, as in earcut's locallyInside. LUCENE-8775 ("Improve tessellator to handle better cases where a hole share a vertex with the polygon", 8.2.0) added area == 0 -> false; its PR and issue discuss diagonal choice and shared vertices only.
  • Precedent: comparable "Unable to Tessellate" reports on valid polygons were fixed as bugs (#15205 / #15211, #15554 / #15657, #15695 / #15700, #15731 / #15739, #13841 / #13980, ...).
  • Reference behaviour: earcut, whose locallyInside has no such branch (src/earcut.js:738-742 at 934e54d8be), gives area 9 for all four listings and both hole orders; Lucene 8.1.1 accepts every listing.
The devil's-advocate check110 words

The strongest case for "intended" (a devil's-advocate verifier and an independent reviewer from another model family, OpenAI GPT-5.6): the branch is deliberate code, Tessellator is @lucene.internal (:72) and only "inspired by" earcut (:34), the trigger needs exact collinearity, and the failure is loud.

Why it fails: the public XYShape and LatLonShape APIs reach the code with a valid polygon; no javadoc, commit, CHANGES entry or issue documents the limitation; the branch was added for holes that share a vertex, not to reject straight corners; the same ring is accepted when re-listed; and the release before the change handled it. All verifiers rated the "intended" argument weak.

Suggested fix

Recommended After step 1 of fetchHoleBridge, move to a copy of the vertex whose corner contains the hole vertex (validated sketch)

Recommended (validated sketch): in fetchHoleBridge, after the ray picks a vertex, if that copy's corner does not contain the hole vertex, move to a coincident copy whose corner does, reading a straight corner as a half-plane as earcut does (+35 lines in Tessellator.java). The test passes, and so do 390 geo and shape tests and :lucene:core:check.

    // an earlier hole's bridge can leave several copies of this vertex: if the angle of the copy
    // found by the ray does not contain the hole vertex, take a copy whose angle does (step 2
    // below never picks a copy with a straight angle)
    if (isSectorContaining(connection, holeNode) == false) {
      Node q = connection.next;
      while (q != connection) {
        if (isVertexEquals(q, connection) && isSectorContaining(q, holeNode)) {
          connection = q;
          break;
        }
        q = q.next;
      }
    }
All options and trade-offs3 options ยท 491 words

One option was built and validated in a scratch worktree of 5adff78bb168; two simpler changes were tried earlier and are not fixes.

After step 1 of fetchHoleBridge, move to a copy of the vertex whose corner contains the hole vertex (validated sketch) (recommended)

If the vertex found by the ray fails the corner test, walk the ring for a coincident copy that passes it. The test is a private helper, isSectorContaining(a, b): earcut's locallyInside (no area == 0 branch; strict < 0 where isLocallyInside's ccw branch uses <= 0 since #15739), with neighbouring copies of a skipped. isLocallyInside itself, used for diagonals elsewhere, is unchanged.

    // an earlier hole's bridge can leave several copies of this vertex: if the angle of the copy
    // found by the ray does not contain the hole vertex, take a copy whose angle does (step 2
    // below never picks a copy with a straight angle)
    if (isSectorContaining(connection, holeNode) == false) {
      Node q = connection.next;
      while (q != connection) {
        if (isVertexEquals(q, connection) && isSectorContaining(q, holeNode)) {
          connection = q;
          break;
        }
        q = q.next;
      }
    }

Trade-offs

  • A sketch checked at one seed. The extra ring walk runs only when the ray's vertex fails the test (at most one O(n) pass per hole).
  • It touches the same code as LFV-757 (the same area == 0 line, reached there through coincident copies after a shared-vertex merge); this diff leaves LFV-757's polygon failing, and maintainers may prefer one rewrite of copy selection that covers both.

What the checks validated

  • Scratch worktree of 5adff78bb168, gradle, JDK 25, seed DEADBEEF (removed afterwards): the minimal test passes with assertions on and off and fails without the diff; org.apache.lucene.geo.*, document.*Shape* and document.*Polygon*, assertions on: 45 classes, 390 tests, 0 failures (26 skipped); TestTessellator with nightly tests, assertions on: 77 tests, 0 failures, 1 skipped; :lucene:core:check -x test passes.
  • With the patched file applied on main: all four listings and both hole orders give 12 triangles, area 9; the five entry points run succeed; the finder's two-method test and the independent reproducer's test pass.
  • Earlier, the same logic (differing only in names and comments) accepted the five earcut polygons in all 74 configurations, and in the finder's random grid runs removed every disjoint-hole rejection without adding one (not re-checked by a verifier).

Read a straight corner as a half-plane in step 2 only (not a fix)

Makes this repro and every disjoint-hole failure of the grid runs pass.

Trade-offs

In the finder's grid runs it adds rejections for polygons whose holes touch other rings, so it only shows where the bug is.

Delete the area == 0 branch (not recommended)

On main the upstream geo-shape suites still pass (123 tests, one seed).

Trade-offs

On 8.2.0 it breaks that release's own testComplexPolygon31 (a hole sharing a vertex with the outer ring, added with the branch), so the branch protects shared-vertex cases and needs a replacement.

Downstream note

Products that index user-supplied polygons through these APIs see a per-document indexing error for such a polygon; other documents and queries are unaffected.

How it was found

Bounded translation validation of Tessellator.tessellate on small integer grids: random valid polygons with 2-4 holes that touch nothing were tessellated in every ring listing, hole order and grid symmetry and checked for the exact area. A polygon accepted in one form and rejected in another was reduced to this public-API test.

The method in full120 words

Method: bounded translation validation (the hunt's semi-formal tier): the oracle is the polygon's exact area and acceptance, which must not change under re-listing a ring from another vertex, reordering the holes, or rotating or mirroring the grid. The polygons were drawn on 8x8 and 13x13 integer grids, so every coordinate and area is exact in double.

The failing polygons were then reduced to a public-API JUnit test, separated from the other Tessellator defects by applying their fixes (this one still fails), and checked by an independent reproducer, cross-model reviews (OpenAI GPT-5.6), a devil's-advocate verifier and an adjudication. The finder's population counts (for example 9 of 1,455 polygons on 8x8 rejected in some form) were not re-checked by a verifier.

Prior reports, history and checks

Prior reports

No report of this defect (searched 2026-10-27, re-run 2026-11-04). Related items, about other defects: earcut #219 and #221 (similar geometry, but an earcut filtering bug that gives wrong triangles), LUCENE-8775 (#9819, which added the branch for shared-vertex holes) and #15205 (another collinear-bridge regression, fixed by #15211).

Searches3 searches

Related items, none a report of this defect:

  • mapbox/earcut #219 ("Overlapping triangles on a simple polygon with two holes since 3.1.0", 2026-10-17) and #221 ("Triangles outside a simple polygon with two holes, mirror case of #219", 2026-10-19), both by EpicDima and fixed by the earcut maintainer; their PR #220 was closed unmerged. Similar geometry (an earlier hole's bridge continues an outer edge and a later hole's ray hits its end), but a different defect: earcut 3.1.0's per-hole point filtering removed the straight copy and earcut produced wrong triangles; the fixes change earcut code Lucene does not have. Lucene keeps the copy and rejects it in its own area == 0 branch, which earcut's locallyInside does not have. Neither mentions Lucene; their polygons make good extra repros.
  • apache/lucene#9819 (LUCENE-8775, PR lucene-solr#652): the change that introduced the branch, about choosing diagonals and holes that share a vertex; nothing on straight corners.
  • apache/lucene#15205 ("Regression in Tessellator: unable to tessellate previously valid Polygon", 9.12.1), fixed by #15211 ("Fix failure due to hole bridge being coplanar with polygon edge"), which moved the filtering of collinear points from after each hole's bridge to after all holes. Different code; this repro fails before and after it.
  • Others with other causes, all fixed while main still fails: #11986 / #11988, #11506 / #756, LUCENE-9580, LUCENE-9251, #15554 / #15657, #15695 / #15700, #15731 / #15739, #13841 / #13980, #12352 / #12353. The open PRs touching Tessellator.java (#12036, #12295) do not change this logic.

Gaps: GitHub search matches words, not meaning; two Stack Overflow questions could not be read; private channels are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; Elasticsearch, OpenSearch, Solr; mapbox/earcut)2026-10-27three scripted prior-report searches (45, 51 and 51 queries); full listings of every apache/lucene result for the Tessellator terms (138 items, 41 threads read); the changed files of all 408 open PRs; all 221 earcut issues and PRs; the file historyno report; earcut #219 / #221, #9819 and #15205 related
Mailing lists, JIRA archive, web2026-10-27dev@, java-user@, issues@, commits@ (60 tool queries plus 24 direct); 12 JIRA queries; 11 web searchesno report
Re-run: GitHub, lists, JIRA, web2026-11-04a scripted prior-report search with fresh responses (51 queries, 108 hits, all triaged before); GitHub searches for Tessellator items updated since 2026-10-27 and new earcut items; 3 web searches; upstream commits on Tessellator.javanothing new; the area == 0 branch is unchanged on upstream main f72004515ec2

History

Introduced in 8.2.0 by LUCENE-8775 (05ea0f2d54a on main, ca07f444c85 on branch_8x, 2019-06-06), which added if (area == 0) return false to isLocallyInside. Every release from 8.2.0 to 10.6.0 and main fails; 7.5.0 to 8.1.1 pass; swapping only Tessellator.java between 8.1.1 and 8.2.0 isolates the commit.

Versions tested8 rows

Introduced by LUCENE-8775 (apache/lucene#9819; PR apache/lucene-solr#652 by iverase, "Tessellator: Improve the election of diagonals when splitting the polygon"), merged 2019-06-06 as 05ea0f2d54a on the main line (first in 9.0.0) and ca07f444c85 on branch_8x (first in 8.2.0); a branch_8_1 backport shipped in no release. Before it, a straight corner fell into a strict half-plane test.

For this exact polygon the same commit's fetchLeftmost tie-break (lowest y on equal x) is also needed, because hole A has two left-most vertices; a variant with a unique left-most vertex fails with the straight-angle branch alone. 10.6.0 differs from main in Tessellator.java only by comment typos and two dropped final modifiers.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedminimal test fails, assertions on and off (gradle and standalone runner)
10.6.0, 9.12.3 affectedminimal test fails (10.6.0 with assertions on and off)
8.2.0 to 10.5.2 (57 releases) affectedthe version-independent program rejects the listings from (5,1) and (0,0)
7.5.0 to 8.1.1 (9 releases) not affectedevery listing gives area 9
8.1.1 jars + the ca07f444c85 Tessellator.java bisectfails
8.2.0 jars + the 8.1.1 Tessellator.java bisectpasses
main, 8.2.0 and 8.1.1 + ca07f444c85, each minus only the area == 0 branch single hunkpasses
main + the recommended fix not affectedminimal test passes; every listing gives 12 triangles, area 9

Independent checks

The finder's test, an independent reproducer with its own hole, cross-model reviews (OpenAI GPT-5.6), a devil's-advocate verifier and the adjudication (confirmed, high) all call it real; re-runs on main a9779ef3c193, e1faeba08bd and 5adff78bb168 still fail.

Checks and verdicts194 words

Every check re-ran a repro, except the cross-model intended lens, whose reviewer could only read code (a separate run re-ran the test).

Each independent check and its verdict
CheckVerdictWhat it found
finder's public-API test bugXY and LatLon, all four listings, point queries: 2 of 2 fail on main (-ea, -da) and 7 releases; pass with the fix sketch.
independent reproducer realOwn test with another hole A, (3,1) (4,1) (4,2): 2 of 2 fail on main, 10.5.2 and 9.12.3; 8.2.0 fails, 8.1.1 passes; 32 of 64 explored configurations fail, always the same two listings.
cross-model reviews (OpenAI GPT-5.6) real (high; intended lens medium)Refute and root-cause lenses executed their own runs; the intended lens read only and rated the intended argument weak.
devil's-advocate verifier real, highBuilt the strongest intended case and rejected it; earcut triangulates the polygon exactly for all 8 encodings.
adjudication confirmed, highRe-ran the independent test with a fresh seed: 2 of 2 fail, 2 of 2 pass with the fix sketch.
re-runs on later main still reproducese1faeba08bd (2026-11-02) and 5adff78bb168 (this doc), assertions on and off.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,655 words

Finding record

LFV-795, snapshot of 2026-11-06

Registry title
Tessellator rejects a valid polygon whose holes touch nothing, depending on the outer ring's start vertex, when an earlier hole's bridge continues an outer edge: isLocallyInside treats the resulting straight angle as never inside, so fetchHoleBridge cannot switch to the only correct copy of the bridge end (regression in 8.2.0)
Area
geo / Tessellator hole bridges (fetchHoleBridge, isLocallyInside) behind LatLonShape and XYShape polygon indexing
Priority
Low, rank 401: Since 8.2.0, a valid polygon whose holes touch nothing can be rejected loudly with 'Unable to Tessellate shape' for some ring starts, and no wrong triangles are indexed; it is older and reached by more inputs than the narrow 10.x crash regressions LFV-762 and LFV-755, so it heads the loud Tessellator crash group below the silent rows.
Impact class
crash
Impact note
Hits applications on 8.2.0 to 10.6.0 and main that index polygons with two or more holes through the default LatLonShape or XYShape createIndexableFields (default codec and IndexWriterConfig, either checkSelfIntersections setting, no scale needed; createDocValueField calls the same Tessellator.tessellate and fails the same way, executed on main 5adff78bb168): when one hole's bridge continues an outer-ring edge in a straight line and a later hole's leftward ray passes exactly through that bridge end, the call throws IllegalArgumentException 'Unable to Tessellate shape. Possible malformed shape detected.' for some outer-ring start vertices (2 of 4 in the repro, either hole order) and the document cannot be indexed; the failure is loud, the other start vertices give the exact triangulation, no wrong query result was verified, and the trigger needs exact collinearity, which grid-snapped coordinates can produce and generic floating-point coordinates rarely do.
Summary
You index a valid polygon with two holes that touch nothing, for example the clockwise outer ring (4,5) (5,1) (0,0) (1,1) with the counter-clockwise holes (2,2) (4,3) (3,3) and (3,2) (3,1) (4,1), and you expect XYShape or LatLonShape.createIndexableFields to return its triangles whichever vertex the outer ring starts at. The first hole is joined to the ring by a bridge from (2,2) to (1,1) that continues the outer edge (0,0)-(1,1) in a straight line; the second hole's bridge search then lands on (1,1), and the only copy of (1,1) whose angle contains that hole has a straight angle, which Tessellator.isLocallyInside always rejects ('area == 0 -> return false'). When the outer ring starts at (5,1) or (0,0) the search keeps the other copy, the bridge crosses the ring and indexing throws 'Unable to Tessellate shape. Possible malformed shape detected.', while the starts (4,5) and (1,1) give the exact area 9; 8.1.1 accepts every start, 8.2.0 to main reject two of them.
Introduced
05ea0f2d54afd91b10b845c7b01e6738f0de4a78 on main (then 'master'; first in 9.0.0) and its same-day branch_8x backport ca07f444c85da5dc45cd794c0410a90be251d29c (identical Tessellator.java, git blob 195e4a6c4d3; the commit 8.2.0 shipped): apache/lucene-solr#652, LUCENE-8775 (apache/lucene#9819) 'Tessellator: Improve the election of diagonals when splitting the polygon' by iverase, merged 2019-06-06. It added 'if (area == 0) { // parallel return false; }' to isLocallyInside; before it a straight angle fell into the ccw branch, a strict half-plane test, as in earcut's locallyInside [read: git log -S 'area == 0' and blame of Tessellator.java:1255-1257 on main a9779ef3c193; exec: 8.1.1 jars with the 8.1.1 Tessellator.java pass and with the ca07f444c85 file fail, 8.2.0 jars with the 8.1.1 file pass, and removing only the straight-angle branch makes the repro pass on 8.1.1+ca07f444c85, 8.2.0 and main]. The same commit's fetchLeftmost tie-break (lowest y on equal x) is also needed for the exact repro polygon, whose second hole has two left-most vertices; a variant with a unique left-most vertex fails with the straight-angle hunk alone on 8.1.1 [exec], so the tie-break is not the defect. A branch_8_1 backport (336eff705a9, 2019-06-25) shipped in no release. (2019-06-06 (05ea0f2d54a on main and ca07f444c85 on branch_8x, the same day))
First affected version
8.2.0 [exec]; the last release before it, 8.1.1, accepts the polygon for every outer-ring start [exec] (8.1.2 was never released)
Affected versions
every release from 8.2.0 through 10.6.0 (58 releases) and main a9779ef3c193 [exec]: the one-method public-API test fails on the released 10.6.0 (-ea and -da) and 9.12.3 jars and on main 5adff78bb168 (2026-11-04); the follow-up's version-independent main() (LatLonShape.createIndexableFields and Tessellator.tessellate, exact area and hole-point check, all 4 outer-ring starts) fails on all 57 releases and on main; the finder's public-API JUnit (XY and LatLon, both checkSelfIntersections values, point queries) fails 2 of 2 on main (-ea and -da), 10.5.2, 10.4.0, 10.3.2, 10.0.0, 9.12.3, 9.12.2 and 9.1.0; the independent reproduction's own JUnit fails on main (-ea, -da), 10.5.2 and 9.12.3 and its main() on 8.2.0. Not affected [exec]: the 9 releases 7.5.0 to 8.1.1 (7.5.0 is the first with LatLonShape and the Tessellator). XYShape exists from 8.2.0; before 9.1.0 only the LatLonShape path was executed. The five polygons published in mapbox/earcut #219 and #221 are rejected by main, 10.5.2 and 9.12.2 in 18 of 74 start-vertex/hole-order configurations, by 8.2.0 in 10 and by 8.1.1 in none [exec]. Not fixed upstream: no logic change to Tessellator.java after #15739 on main 20ced71c1e6, branch_10x, branch_10_5 and branch_9_12, and isLocallyInside still has the straight-angle branch [read, 2026-10-27].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
no Lucene report found; related items outside Lucene, about a different defect (searched 2026-10-27, read-only, including the finder's search): mapbox/earcut, the JavaScript library Lucene's Tessellator is based on, received two reports by EpicDima: #219 'Overlapping triangles on a simple polygon with two holes since 3.1.0' (2026-10-17, fixed by the maintainer in 77019d32d4) and #221 'Triangles outside a simple polygon with two holes, mirror case of #219' (2026-10-19, fixed in 934e54d8be), plus the unmerged PR #220. They describe the same trigger geometry: an earlier hole's bridge continues an outer edge in a straight line and a later hole's ray hits that bridge end, so the later hole is bridged to the copy whose angle does not contain it. In earcut the straight copy is removed by its per-hole filterPoints and the result is wrong triangles; Lucene keeps the copy and rejects it in its own 'area == 0 -> false' branch of isLocallyInside, which earcut's locallyInside never had, and the result is a rejected polygon. The five published polygons are rejected by Lucene main, 10.5.2 and 9.12.2 in 18 of 74 start-vertex/hole-order configurations, by 8.2.0 in 10 and by 8.1.1 in none [exec]; none of the earcut items mentions Lucene. In Lucene, no issue, PR, review comment, list message (dev@, java-user@, issues@, commits@), JIRA entry or downstream report (Elasticsearch, OpenSearch, Solr) describes this defect: three scripted prior-report searches (45, 51 and 51 queries; 135, 113 and 183 distinct hits), full-text listings of every apache/lucene result page for the Tessellator terms (138 items, 41 threads read), the changed files of all 408 open apache/lucene PRs (only #12036 and #12295 touch Tessellator.java, neither changes fetchHoleBridge or isLocallyInside), all 221 mapbox/earcut issues and PRs, and the file history. Closest Lucene items, all with another root cause and all in main, which still fails: LUCENE-8775 (#9819, lucene-solr#652, the introducing change; no discussion of straight angles), #11986/#11988 (the step-2 copy walk skipped the first node), #11506/#756, LUCENE-9580, #15205/#15211, #15554/#15657, #15695/#15700, #15731/#15739, #13841/#13980, #12352/#12353. Not fixed upstream (main head 20ced71c1e6, branch_10x, branch_10_5, branch_9_12).
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT, commit of 2026-11-02): the one-method test through gradle in a scratch worktree, assertions on and off, stock and with the fix, plus the geo and shape suites, TestTessellator with nightly tests and :lucene:core:check -x test with the fix; the standalone runner on main (seeds DEADBEEF and 5EED1234, assertions on and off) and on the 10.6.0 and 9.12.3 release jars; probes of all four ring listings, both hole orders and the five public polygon entry points run, stock and with the fix, and a print-only trace of the bridge choice. Earlier runs: main a9779ef3c193 (2026-10-27) and e1faeba08bd (2026-11-02), and 66 release jars from 7.5.0 to 10.5.2. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:34-38 (at a9779ef3c193) (class javadoc: 'inspired by mapbox's earcut algorithm'), :40-50 (NOTES: 'Requires valid polygons: No self intersections; Holes must be inside the polygon; Holes may only touch at one vertex; Polygon must have an area'), :72 (@lucene.internal), :141-144 (IllegalArgumentException 'Unable to Tessellate shape. Possible malformed shape detected.'), :372-399 (eliminateHole: fetchHoleBridge, then splitPolygon, which leaves two copies of the bridge end), :536 fetchHoleBridge, :542-561 (step 1: leftward ray, the first hit in ring order is kept), :562-566 (null and hx == qx exits), :568-591 (step 2: a candidate replaces the step-1 connection only if isLocallyInside(p, holeNode)), :1251-1268 (isLocallyInside; :1255-1257 'if (area == 0) { // parallel return false; }')
  • lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:85-114 (at a9779ef3c193) and lucene/core/src/java/org/apache/lucene/document/XYShape.java:83-112 (createIndexableFields and createDocValueField call Tessellator.tessellate at LatLonShape.java:102,114 and XYShape.java:100,112); lucene/core/src/java/org/apache/lucene/geo/Polygon.java:31-32 (NOTES: 'Coordinates must be in clockwise order, except for holes. Holes must be in counter-clockwise order.')
The finding in 30 seconds
  • a Lucene main checkout (5adff78bb168) lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:310-325 (hole order), :372-399 (eliminateHole), :536-591 (fetchHoleBridge steps 1 and 2; :549 strict x > qx, :555 connection, :584 isLocallyInside), :600-601 (fetchLeftmost tie-break), :681-688 (SPLIT failure throws), :1175-1199 (splitPolygon copies), :1251-1268 (isLocallyInside; :1255-1257 the area == 0 branch)
Who is affected
  • a Lucene main checkout (5adff78bb168) lucene/core/src/java/org/apache/lucene/document/LatLonShape.java:85-114, XYShape.java:83-112
Why it looks unintended
  • a Lucene main checkout (5adff78bb168) lucene/core/src/java/org/apache/lucene/geo/Tessellator.java:34, :40-50, :72
  • a Lucene main checkout (5adff78bb168) lucene/core/src/java/org/apache/lucene/geo/Polygon.java:31-32
History
  • a Lucene clone: git diff releases/lucene/10.6.0 5adff78bb168 -- lucene/core/src/java/org/apache/lucene/geo/Tessellator.java

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel with this doc from the executed example on main 5adff78bb168.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.