LatLonShapeandXYShapeindex a polygon as triangles, computed byTessellator.tessellate(ear clipping, inspired by the earcut library).- 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.
- 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).

- Build an
XYPolygonwith 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. - You expect
XYShape.createIndexableFieldsto return 12 triangles with a total area of 9, as it does when the same ring is listed from (4,5) or (1,1). - Instead it throws
IllegalArgumentException: Unable to Tessellate shape. Possible malformed shape detected.Listed from (0,0) it fails too, and so doLatLonShapeand the doc-values fields: the document cannot be indexed. - 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.
- Hole A's leftward ray from (3,1) lands on (1,1) and keeps whichever copy comes first from the ring's start.
isLocallyInsidereturns 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.
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.
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 from | result 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.
- Holes are joined one at a time, sorted by their left-most vertex (
:310-323,:325). For each,fetchHoleBridge(:536) picks an outer vertex andsplitPolygon(:399,:1175-1199) cuts a two-way bridge, duplicating both ends (:1176-1177). - 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.
- Hole A's bridge vertex is (3,1) (its left-most, lowest vertex;
fetchLeftmosttie-break:600-601). Step 1 offetchHoleBridge(: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 > qxis 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. - Step 2 (
:568-591) replaces the step-1 vertex only with one that passesisLocallyInside(p, holeNode)(:584).isLocallyInside(:1251-1268) starts withif (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. - The bridge from the wrong copy runs outside that copy's corner, ear clipping finds no ear and no valid split, and
earcutLinkedListthrows (:681-688).
Exposure and likelihood184 words
- Who is hit: applications that index polygons with two or more holes through
LatLonShapeorXYShape(createIndexableFieldsandcreateDocValueField;LatLonShape.java:102,114,XYShape.java:100,112), with the default codec andIndexWriterConfig, eithercheckSelfIntersectionsvalue. 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.
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.
/*
* 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);
}
}
./gradlew :lucene:core:test --tests org.apache.lucene.document.TestTessellatorDisjointHolesCollinearBridge -Ptests.seed=DEADBEEF
- Expected
createIndexableFieldsreturns 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.fromTessellator.earcutLinkedList, with assertions on and off.
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.
| lucene | main 5adff78bb168 (12.0.0-SNAPSHOT); also the 10.6.0 and 9.12.3 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 |
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)
# 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- 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 == 0branch 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) addedarea == 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
locallyInsidehas no such branch (src/earcut.js:738-742at934e54d8be), 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.
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 == 0line, 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*anddocument.*Polygon*, assertions on: 45 classes, 390 tests, 0 failures (26 skipped);TestTessellatorwith nightly tests, assertions on: 77 tests, 0 failures, 1 skipped;:lucene:core:check -x testpasses. - 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).
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.
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.
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.
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.
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 == 0branch, which earcut'slocallyInsidedoes 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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs, commits; Elasticsearch, OpenSearch, Solr; mapbox/earcut) | 2026-10-27 | three 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 history | no report; earcut #219 / #221, #9819 and #15205 related |
| Mailing lists, JIRA archive, web | 2026-10-27 | dev@, java-user@, issues@, commits@ (60 tool queries plus 24 direct); 12 JIRA queries; 11 web searches | no report |
| Re-run: GitHub, lists, JIRA, web | 2026-11-04 | a 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.java | nothing new; the area == 0 branch is unchanged on upstream main f72004515ec2 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | minimal test fails, assertions on and off (gradle and standalone runner) |
| 10.6.0, 9.12.3 | affected | minimal test fails (10.6.0 with assertions on and off) |
| 8.2.0 to 10.5.2 (57 releases) | affected | the version-independent program rejects the listings from (5,1) and (0,0) |
| 7.5.0 to 8.1.1 (9 releases) | not affected | every listing gives area 9 |
8.1.1 jars + the ca07f444c85 Tessellator.java | bisect | fails |
| 8.2.0 jars + the 8.1.1 Tessellator.java | bisect | passes |
main, 8.2.0 and 8.1.1 + ca07f444c85, each minus only the area == 0 branch | single hunk | passes |
| main + the recommended fix | not affected | minimal test passes; every listing gives 12 triangles, area 9 |
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).
| Check | Verdict | What it found |
|---|---|---|
| finder's public-API test | bug | XY 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 | real | Own 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, high | Built the strongest intended case and rejected it; earcut triangulates the polygon exactly for all 8 encodings. |
| adjudication | confirmed, high | Re-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 reproduces | e1faeba08bd (2026-11-02) and 5adff78bb168 (this doc), assertions on and off. |
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.
- PNG 439 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,655 words
- 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,TestTessellatorwith nightly tests and:lucene:core:check -x testwith 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: maina9779ef3c193(2026-10-27) ande1faeba08bd(2026-11-02), and 66 release jars from 7.5.0 to 10.5.2. OpenJDK 25.0.3, Linux aarch64.
- 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, :72a 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
- TestTessellatorDisjointHolesCollinearBridge.java (one test, public API):
repro/LFV-795/src/org/apache/lucene/document/TestTessellatorDisjointHolesCollinearBridge.java(35 lines)
Made in parallel with this doc from the executed example on main 5adff78bb168.
assets/LFV-795/bug.png(439 KB)
