From 1c00ff3bca51c012541347497e123bbc96ce1969 Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 12 Jun 2026 01:45:41 +0100 Subject: [PATCH 1/2] feat(charts): smooth lines render as native cubic Beziers via PathNode W3 of the path workstream: LineChartLayout drops the fixed 8-sub-segment Catmull-Rom sampler entirely - smooth runs (3+ points) compile into one stroked PathNode per run with Catmull-Rom-derived Bezier control points (c1 = p1 + (p2-p0)/6, c2 = p2 - (p3-p1)/6, the exact continuous curve the sampler approximated), and smooth area fills close the very same curve down to the baseline so fill and stroke edges coincide. Two-point runs stay straight segments; non-smooth charts are untouched (committed snapshot baselines unaffected - verified, all chart snapshot tests green). Resolver tests rewritten: one-native-run, curved-area shape, two-point fallback. Regenerated chart-showcase and feature-catalog previews - the smooth page now has zero facets. --- CHANGELOG.md | 10 +- assets/readme/examples/chart-showcase.pdf | Bin 14376 -> 13688 bytes assets/readme/examples/feature-catalog.pdf | Bin 1814164 -> 1813476 bytes .../document/chart/LineChartLayout.java | 196 +++++++++++++----- .../chart/ChartLayoutResolverTest.java | 58 +++++- 5 files changed, 207 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b929ccf4..affc371d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,10 +30,12 @@ Entries land here as they merge. placement.** `ChartSpec.bar().horizontal(true)` transposes the chart (categories on Y in reading order, value axis on X, labels at bar ends); stacked bars label the category total. `ChartSpec.line().smooth(true)` - draws deterministic Catmull-Rom curves (fixed 8 sub-segments per span); - `.area(true)` fills each series down to the baseline with a translucent - series colour (`ChartStyle.areaOpacity`, default 0.35) — alpha-blended - fills layer legibly. `LegendPosition.TOP` and `RIGHT` now lay out as a top + draws deterministic Catmull-Rom curves as **native cubic Béziers** through + the vector path primitive — one `PathNode` per run, perfectly smooth at + any zoom level, zero tessellation; `.area(true)` fills each series down to + the baseline with a translucent series colour (`ChartStyle.areaOpacity`, + default 0.35) — alpha-blended fills layer legibly, and in smooth mode the + fill closes the exact stroke curve so fill and stroke edges coincide. `LegendPosition.TOP` and `RIGHT` now lay out as a top strip / right column for every chart kind, including pie. The chart resolver is split per kind (`BarChartLayout` / `LineChartLayout` / `PieChartLayout` over a shared `ChartLayoutSupport`). diff --git a/assets/readme/examples/chart-showcase.pdf b/assets/readme/examples/chart-showcase.pdf index 5c0aa91aa1c5dc43875a3c9949c7697776d85002..96e0e4d54f3dc5880b358410997cad38694a9832 100644 GIT binary patch delta 3203 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 TV|)M_Fc=gDke4^HL<9a0VnP-@ delta 3883 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 JT~SL3^naiu%9Q{B delta 6485 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 X1nHwy^dv-r5HWs2Ha1x`1mS-HMu%VK diff --git a/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java b/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java index cc063972..93195d1f 100644 --- a/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java +++ b/src/main/java/com/demcha/compose/document/chart/LineChartLayout.java @@ -1,6 +1,7 @@ package com.demcha.compose.document.chart; import com.demcha.compose.document.node.EllipseNode; +import com.demcha.compose.document.node.PathNode; import com.demcha.compose.document.node.PolygonNode; import com.demcha.compose.document.style.*; @@ -10,19 +11,20 @@ import static com.demcha.compose.document.chart.ChartLayoutSupport.*; /** - * Geometry for line charts: straight or Catmull-Rom-smoothed polylines, - * optional translucent area fills, point markers, and collision-aware value - * labels. + * Geometry for line charts: straight polylines or native cubic-Bézier + * smoothed curves, optional translucent area fills (curved to match in + * smooth mode), point markers, and collision-aware value labels. + * + *

Smooth runs compile into a single {@code PathNode} per run whose + * Catmull-Rom-derived control points are pure arithmetic on the data points + * — the exact continuous curve the pre-1.8 fixed-step sampler approximated, + * now rendered with native PDF curve operators and zero tessellation.

* * @author Artem Demchyshyn * @since 1.8.0 */ final class LineChartLayout { - /** - * Sub-segments per Catmull-Rom span; fixed so geometry stays deterministic. - */ - private static final int SMOOTH_SUBDIVISIONS = 8; private static final double DEFAULT_AREA_OPACITY = 0.35; private LineChartLayout() { @@ -52,43 +54,57 @@ static List resolve(ChartSpec.Line line, ChartStyle style, double strokeWidth = style.lineWidth() == null ? DEFAULT_LINE_WIDTH : style.lineWidth(); double areaOpacity = style.areaOpacity() == null ? DEFAULT_AREA_OPACITY : style.areaOpacity(); - // Per-series sampled polylines: contiguous non-null runs, optionally - // Catmull-Rom smoothed. Samples drive area fills and stroke segments; - // markers and labels stay on the original data points. - List>> sampledRuns = new ArrayList<>(); + // Per-series contiguous non-null runs of original data points. Smooth + // mode compiles each run into native Bézier primitives; markers and + // labels stay on the original data points either way. + List>> seriesRuns = new ArrayList<>(); for (int s = 0; s < data.seriesCount(); s++) { - sampledRuns.add(sampleSeries(data.series().get(s), f, slotW, line.smooth())); + seriesRuns.add(sampleSeries(data.series().get(s), f, slotW)); } + boolean smooth = line.smooth(); - // Pass 0 — area fills, under every stroke. + // Pass 0 — area fills, under every stroke. Smooth runs close the + // exact stroke curve down to the baseline so fill and stroke edges + // coincide. if (line.area()) { for (int s = 0; s < data.seriesCount(); s++) { DocumentColor color = style.paintForSeries(s, theme.palette()).primaryColor(); DocumentColor fill = color.withOpacity(areaOpacity); int runIndex = 0; - for (List run : sampledRuns.get(s)) { + for (List run : seriesRuns.get(s)) { if (run.size() < 2) { runIndex++; continue; } - emitAreaPolygon(out, "area_s" + s + "_r" + runIndex, run, - f.plotBottomY(), fill); + String name = "area_s" + s + "_r" + runIndex; + if (smooth && run.size() >= 3) { + emitCurvedArea(out, name, run, f.plotBottomY(), fill); + } else { + emitAreaPolygon(out, name, run, f.plotBottomY(), fill); + } runIndex++; } } } - // Pass 1 — every series' stroke segments. + // Pass 1 — series strokes: one native Bézier path per smooth run + // (three or more points), straight segments otherwise. for (int s = 0; s < data.seriesCount(); s++) { DocumentColor color = style.paintForSeries(s, theme.palette()).primaryColor(); DocumentStroke stroke = DocumentStroke.of(color, strokeWidth); int n = 0; - for (List run : sampledRuns.get(s)) { - for (int i = 1; i < run.size(); i++) { - out.add(segment("line_s" + s + "_seg" + n++, - run.get(i - 1)[0], run.get(i - 1)[1], - run.get(i)[0], run.get(i)[1], stroke)); + int runIndex = 0; + for (List run : seriesRuns.get(s)) { + if (smooth && run.size() >= 3) { + out.add(bezierRun("line_s" + s + "_curve" + runIndex, run, stroke)); + } else { + for (int i = 1; i < run.size(); i++) { + out.add(segment("line_s" + s + "_seg" + n++, + run.get(i - 1)[0], run.get(i - 1)[1], + run.get(i)[0], run.get(i)[1], stroke)); + } } + runIndex++; } } @@ -133,18 +149,18 @@ static List resolve(ChartSpec.Line line, ChartStyle style, } /** - * Splits a series into contiguous non-null runs of (x, y) samples. + * Splits a series into contiguous non-null runs of (x, y) data points. */ private static List> sampleSeries(ChartData.Series series, ChartLayoutSupport.Frame f, - double slotW, boolean smooth) { + double slotW) { List> runs = new ArrayList<>(); List current = new ArrayList<>(); for (int c = 0; c < series.values().size(); c++) { Double v = series.values().get(c); if (v == null) { if (!current.isEmpty()) { - runs.add(smooth ? smoothRun(current) : current); + runs.add(current); current = new ArrayList<>(); } continue; @@ -153,44 +169,128 @@ private static List> sampleSeries(ChartData.Series series, f.plotLeftX() + (c + 0.5) * slotW, f.yForValue(v)}); } if (!current.isEmpty()) { - runs.add(smooth ? smoothRun(current) : current); + runs.add(current); } return runs; } /** - * Subdivides a run with a centripetal-style Catmull-Rom spline (uniform - * parameterisation, clamped endpoints) into {@link #SMOOTH_SUBDIVISIONS} - * sub-segments per span. Pure arithmetic on the input points. + * Uniform Catmull-Rom control points (tension 0.5, clamped endpoints) + * for every span of a run: {@code c1 = p1 + (p2 - p0) / 6}, + * {@code c2 = p2 - (p3 - p1) / 6}. Pure arithmetic on the data points — + * the exact continuous curve the pre-1.8 fixed-step sampler approximated. + * Returns one {@code [c1, c2]} pair per span. */ - private static List smoothRun(List points) { - if (points.size() < 3) { - return points; - } - List samples = new ArrayList<>(); - samples.add(points.get(0)); + private static List catmullRomControls(List points) { + List controls = new ArrayList<>(points.size() - 1); for (int i = 0; i < points.size() - 1; i++) { double[] p0 = points.get(Math.max(0, i - 1)); double[] p1 = points.get(i); double[] p2 = points.get(i + 1); double[] p3 = points.get(Math.min(points.size() - 1, i + 2)); - for (int t = 1; t <= SMOOTH_SUBDIVISIONS; t++) { - double u = (double) t / SMOOTH_SUBDIVISIONS; - samples.add(new double[]{ - catmullRom(p0[0], p1[0], p2[0], p3[0], u), - catmullRom(p0[1], p1[1], p2[1], p3[1], u)}); + double[] c1 = {p1[0] + (p2[0] - p0[0]) / 6.0, p1[1] + (p2[1] - p0[1]) / 6.0}; + double[] c2 = {p2[0] - (p3[0] - p1[0]) / 6.0, p2[1] - (p3[1] - p1[1]) / 6.0}; + controls.add(new double[][]{c1, c2}); + } + return controls; + } + + /** + * One stroked native-Bézier {@code PathNode} primitive covering a whole + * smooth run. The box is the bounding box of the data points and every + * control point, so normalized coordinates stay within the unit box by + * construction. + */ + private static ChartPrimitive bezierRun(String name, List run, DocumentStroke stroke) { + List controls = catmullRomControls(run); + double minX = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY; + double maxY = Double.NEGATIVE_INFINITY; + for (double[] p : run) { + minX = Math.min(minX, p[0]); + maxX = Math.max(maxX, p[0]); + minY = Math.min(minY, p[1]); + maxY = Math.max(maxY, p[1]); + } + for (double[][] c : controls) { + for (double[] p : c) { + minX = Math.min(minX, p[0]); + maxX = Math.max(maxX, p[0]); + minY = Math.min(minY, p[1]); + maxY = Math.max(maxY, p[1]); } } - return samples; + double w = Math.max(MIN_SEGMENT, maxX - minX); + double h = Math.max(MIN_SEGMENT, maxY - minY); + + List segments = new ArrayList<>(run.size()); + segments.add(DocumentPathSegment.moveTo( + (run.get(0)[0] - minX) / w, (run.get(0)[1] - minY) / h)); + for (int i = 0; i < controls.size(); i++) { + double[][] c = controls.get(i); + double[] end = run.get(i + 1); + segments.add(DocumentPathSegment.cubicTo( + (c[0][0] - minX) / w, (c[0][1] - minY) / h, + (c[1][0] - minX) / w, (c[1][1] - minY) / h, + (end[0] - minX) / w, (end[1] - minY) / h)); + } + PathNode node = new PathNode(name, w, h, segments, null, stroke, + DocumentInsets.zero(), DocumentInsets.zero()); + return new ChartPrimitive(node, minX, minY, w, h); } - private static double catmullRom(double p0, double p1, double p2, double p3, double t) { - double t2 = t * t; - double t3 = t2 * t; - return 0.5 * ((2 * p1) - + (-p0 + p2) * t - + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 - + (-p0 + 3 * p1 - 3 * p2 + p3) * t3); + /** + * Curved area fill for a smooth run: the exact stroke curve closed down + * to the plot baseline with two straight edges, emitted as one filled + * {@code PathNode}. + */ + private static void emitCurvedArea(List out, String name, + List run, double baselineY, + DocumentColor fill) { + List controls = catmullRomControls(run); + double minX = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY; + double minY = baselineY; + double maxY = baselineY; + for (double[] p : run) { + minX = Math.min(minX, p[0]); + maxX = Math.max(maxX, p[0]); + minY = Math.min(minY, p[1]); + maxY = Math.max(maxY, p[1]); + } + for (double[][] c : controls) { + for (double[] p : c) { + minX = Math.min(minX, p[0]); + maxX = Math.max(maxX, p[0]); + minY = Math.min(minY, p[1]); + maxY = Math.max(maxY, p[1]); + } + } + double w = Math.max(1.0, maxX - minX); + double h = Math.max(1.0, maxY - minY); + + List segments = new ArrayList<>(run.size() + 3); + segments.add(DocumentPathSegment.moveTo( + (run.get(0)[0] - minX) / w, (run.get(0)[1] - minY) / h)); + for (int i = 0; i < controls.size(); i++) { + double[][] c = controls.get(i); + double[] end = run.get(i + 1); + segments.add(DocumentPathSegment.cubicTo( + (c[0][0] - minX) / w, (c[0][1] - minY) / h, + (c[1][0] - minX) / w, (c[1][1] - minY) / h, + (end[0] - minX) / w, (end[1] - minY) / h)); + } + double baselineNorm = (baselineY - minY) / h; + segments.add(DocumentPathSegment.lineTo( + (run.get(run.size() - 1)[0] - minX) / w, baselineNorm)); + segments.add(DocumentPathSegment.lineTo( + (run.get(0)[0] - minX) / w, baselineNorm)); + segments.add(DocumentPathSegment.close()); + + PathNode node = new PathNode(name, w, h, segments, fill, null, + DocumentInsets.zero(), DocumentInsets.zero()); + out.add(new ChartPrimitive(node, minX, minY, w, h)); } /** diff --git a/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java b/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java index d5a373d3..5b2f12e7 100644 --- a/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java +++ b/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java @@ -344,7 +344,7 @@ void areaFillsRenderUnderEveryStroke() { } @Test - void smoothLineSubdividesEachSpan() { + void smoothLineEmitsOneNativeBezierRun() { ChartData data = ChartData.builder().categories("A", "B", "C") .series("S", 1.0, 3.0, 2.0).build(); ChartSpec.Line line = ChartSpec.line().data(data).smooth(true).build(); @@ -352,10 +352,58 @@ void smoothLineSubdividesEachSpan() { List out = ChartLayoutResolver.resolve( line, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS); - long segments = out.stream() - .filter(p -> p.node().name().startsWith("line_s0_seg")).count(); - // Two spans, eight sub-segments each. - assertThat(segments).isEqualTo(16); + // No tessellated sub-segments — the whole run is one native path. + assertThat(out.stream().noneMatch(p -> p.node().name().startsWith("line_s0_seg"))) + .isTrue(); + com.demcha.compose.document.node.PathNode curve = + (com.demcha.compose.document.node.PathNode) byName(out, "line_s0_curve0").node(); + // MoveTo plus one cubic span per data gap. + assertThat(curve.segments()).hasSize(3); + assertThat(curve.segments().get(0)) + .isInstanceOf(com.demcha.compose.document.style.DocumentPathSegment.MoveTo.class); + assertThat(curve.segments().get(1)) + .isInstanceOf(com.demcha.compose.document.style.DocumentPathSegment.CubicTo.class); + assertThat(curve.segments().get(2)) + .isInstanceOf(com.demcha.compose.document.style.DocumentPathSegment.CubicTo.class); + assertThat(curve.stroke()).isNotNull(); + assertThat(curve.fillColor()).isNull(); + } + + @Test + void smoothAreaClosesTheExactCurveDownToTheBaseline() { + ChartData data = ChartData.builder().categories("A", "B", "C") + .series("S", 1.0, 3.0, 2.0).build(); + ChartSpec.Line line = ChartSpec.line().data(data).smooth(true).area(true).build(); + + List out = ChartLayoutResolver.resolve( + line, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS); + + com.demcha.compose.document.node.PathNode area = + (com.demcha.compose.document.node.PathNode) byName(out, "area_s0_r0").node(); + // moveTo + 2 cubic spans + 2 baseline edges + close. + assertThat(area.segments()).hasSize(6); + assertThat(area.segments().get(3)) + .isInstanceOf(com.demcha.compose.document.style.DocumentPathSegment.LineTo.class); + assertThat(area.segments().get(5)) + .isInstanceOf(com.demcha.compose.document.style.DocumentPathSegment.Close.class); + assertThat(area.fillColor()).isNotNull(); + assertThat(area.fillColor().color().getAlpha()).isLessThan(255); + assertThat(area.stroke()).isNull(); + } + + @Test + void twoPointSmoothRunFallsBackToAStraightSegment() { + ChartData data = ChartData.builder().categories("A", "B") + .series("S", 1.0, 3.0).build(); + ChartSpec.Line line = ChartSpec.line().data(data).smooth(true).build(); + + List out = ChartLayoutResolver.resolve( + line, baseStyle(), ChartDefaults.DEFAULT_THEME, 200.0, 120.0, METRICS); + + // A two-point run has no curvature to express — it stays a segment. + byName(out, "line_s0_seg0"); + assertThat(out.stream().noneMatch(p -> p.node().name().startsWith("line_s0_curve"))) + .isTrue(); } @Test From abc52a4fc16d590d870add49786a14f74dfad94c Mon Sep 17 00:00:00 2001 From: DemchaAV Date: Fri, 12 Jun 2026 01:52:18 +0100 Subject: [PATCH 2/2] review: path workstream senior-review fixes - handler @since tags, smooth z-order assertion, build() reuse contract --- .../pdf/handlers/PdfPathFragmentRenderHandler.java | 1 + .../handlers/PdfPolygonFragmentRenderHandler.java | 1 + .../demcha/compose/document/dsl/PathBuilder.java | 4 +++- .../document/chart/ChartLayoutResolverTest.java | 13 +++++++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java index 080d8509..119566b4 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPathFragmentRenderHandler.java @@ -13,6 +13,7 @@ * operators — curves stay smooth at any zoom level. * * @author Artem Demchyshyn + * @since 1.8.0 */ public final class PdfPathFragmentRenderHandler implements PdfFragmentRenderHandler { diff --git a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java index f2d8bc02..b4a4d5a2 100644 --- a/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java +++ b/src/main/java/com/demcha/compose/document/backend/fixed/pdf/handlers/PdfPolygonFragmentRenderHandler.java @@ -12,6 +12,7 @@ * Renders fixed polygon fragments (diamond, triangle, star, arbitrary rings). * * @author Artem Demchyshyn + * @since 1.8.0 */ public final class PdfPolygonFragmentRenderHandler implements PdfFragmentRenderHandler { diff --git a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java index 74b6a20f..e95d63f9 100644 --- a/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java +++ b/src/main/java/com/demcha/compose/document/dsl/PathBuilder.java @@ -205,7 +205,9 @@ public PathBuilder margin(DocumentInsets margin) { } /** - * Builds the path node. + * Builds the path node. The built node copies the segment list, so the + * builder may keep accumulating segments afterwards — each {@code build()} + * snapshots the configuration at that moment. * * @return path node * @throws IllegalArgumentException if the segments do not start with a diff --git a/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java b/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java index 5b2f12e7..575efbfb 100644 --- a/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java +++ b/src/test/java/com/demcha/compose/document/chart/ChartLayoutResolverTest.java @@ -389,6 +389,19 @@ void smoothAreaClosesTheExactCurveDownToTheBaseline() { assertThat(area.fillColor()).isNotNull(); assertThat(area.fillColor().color().getAlpha()).isLessThan(255); assertThat(area.stroke()).isNull(); + // The curved fill still paints under the curved stroke. + int areaIndex = -1; + int curveIndex = -1; + for (int i = 0; i < out.size(); i++) { + String name = out.get(i).node().name(); + if (name.equals("area_s0_r0")) { + areaIndex = i; + } + if (name.equals("line_s0_curve0")) { + curveIndex = i; + } + } + assertThat(areaIndex).isLessThan(curveIndex); } @Test