From 991e3d72a707d4c42888267dd091c269223b7762 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 20 Oct 2026 17:29:14 -0400 Subject: [PATCH 1/4] A straight, untwisted, untapered prim gets one quad per profile edge LLVolume::generate split every profile edge into detail * 0.66 pieces, and gave a straight path at least split + 2 rings, so that vertex lighting and texture interpolation had more vertices across stretched triangles. A default box at LOD 3 carried 96 vertices and 108 triangles where 24 and 12 describe it exactly. On a straight path with no twist and no taper, every side quad is a parallelogram and every face is flat or ruled straight along the path, so the extra points sit on the surface and affine UV interpolation is exact without them. There, split is 0 and the path has 2 rings: a box is 24 / 12 at every LOD, a cylinder 102 / 96 at LOD 3 instead of 152 / 192, and the uncut cube cap a single quad. Twisted, tapered, circular and flexible paths keep their split. A square hole pulled in to its wall is not straight between its corners, so its profile keeps the split points that shape it. Positions and UVs at kept vertices match within 7e-7, and every removed vertex lies on the new surface with the UV it had. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 25 +++++- indra/llmath/tests/llvolume_test.cpp | 128 ++++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 5 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index 44cfcaec08..d576052804 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -2105,9 +2105,10 @@ bool LLVolume::generate() // stretched due to twisting or scaling on the path. S32 split = (S32) ((mDetail)*0.66f); - if (mParams.getPathParams().getCurveType() == LL_PCODE_PATH_LINE && - (mParams.getPathParams().getScale().mV[0] != 1.0f || - mParams.getPathParams().getScale().mV[1] != 1.0f) && + const LLPathParams& path_params = mParams.getPathParams(); + const bool tapered = path_params.getScale().mV[0] != 1.0f || path_params.getScale().mV[1] != 1.0f; + if (path_params.getCurveType() == LL_PCODE_PATH_LINE && + tapered && (mParams.getProfileParams().getCurveType() == LL_PCODE_PROFILE_SQUARE || mParams.getProfileParams().getCurveType() == LL_PCODE_PROFILE_ISOTRI || mParams.getProfileParams().getCurveType() == LL_PCODE_PROFILE_EQUALTRI || @@ -2116,6 +2117,19 @@ bool LLVolume::generate() split = 0; } + // A straight path that neither twists nor tapers sweeps every profile + // edge into a parallelogram, and every face is flat or ruled straight + // along the path: positions and UVs are affine over each quad, and the + // split points and the rings between the ends lie on the triangles the + // ends make. A hole drawn in to its wall is the exception: its split + // points are drawn in too, where the edges they split are not, and + // they shape it. + const S32 lighting_split = split; + if (path_params.getCurveType() == LL_PCODE_PATH_LINE && !tapered && path_params.getTwistBegin() == path_params.getTwist()) + { + split = 0; + } + mLODScaleBias.setVec(0.5f, 0.5f, 0.5f); F32 profile_detail = mDetail; @@ -2138,6 +2152,11 @@ bool LLVolume::generate() bool regenPath = mPathp->generate(mParams.getPathParams(), path_detail, split); bool regenProf = mProfilep->generate(mParams.getProfileParams(), mPathp->isOpen(),profile_detail, split); + if (regenProf && split < lighting_split && mProfilep->holeTouchesWall()) + { + mProfilep->setDirty(); + mProfilep->generate(mParams.getProfileParams(), mPathp->isOpen(), profile_detail, lighting_split); + } if (regenPath || regenProf ) { diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index b92f2a3098..85687b8c1e 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -2535,8 +2535,8 @@ namespace // Vertices transformed a triangle with a 16-entry FIFO cache, and // bytes fetched over the buffer's, per LOD. const Bound bounds[] = { - { "box", { 2.0000, 2.0000, 1.1250, 0.8889 }, { 1.2000, 1.2000, 1.0667, 1.0000 } }, - { "cylinder", { 1.2500, 1.1667, 1.0889, 1.0625 }, { 1.0133, 1.0286, 1.0146, 1.0211 } }, + { "box", { 2.0000, 2.0000, 2.0000, 2.0000 }, { 1.2000, 1.2000, 1.2000, 1.2000 } }, + { "cylinder", { 1.2500, 1.1667, 1.1000, 1.0833 }, { 1.0133, 1.0286, 1.0182, 1.0353 } }, { "sphere", { 0.7778, 0.6944, 0.6429, 1.0833 }, { 1.0286, 1.0240, 1.0000, 1.0043 } }, { "torus", { 0.6806, 1.1111, 1.0667, 1.0417 }, { 1.0122, 1.0080, 1.0000, 1.0010 } }, { "sculpt sphere", { 0.6806, 1.1250, 1.0625, 1.0312 }, { 1.0122, 1.0074, 1.0021, 1.0006 } }, @@ -3505,4 +3505,128 @@ namespace INFO(report.text()); CHECK(report.failures == 0); } + + // ------------------------------------------------------------------ + // Vertex and triangle counts + + struct Counts + { + S32 vertices = 0; + S32 triangles = 0; + + bool operator==(const Counts& other) const { return vertices == other.vertices && triangles == other.triangles; } + }; + + Counts countsOf(const LLVolume& volume) + { + Counts counts; + counts.triangles = volume.getNumTriangles(&counts.vertices); + return counts; + } + + struct ExpectedCounts + { + Shape shape; + Counts lods[LOD_COUNT]; + }; + + void checkCounts(const ExpectedCounts& expected) + { + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + LLPointer volume = build(expected.shape, lod); + const Counts counts = countsOf(*volume); + INFO(fmt::format("{}: {} vertices and {} triangles, where {} and {} are expected", lodName(expected.shape, lod), counts.vertices, + counts.triangles, expected.lods[lod].vertices, expected.lods[lod].triangles)); + CHECK(counts == expected.lods[lod]); + } + } + + // A straight path that neither twists nor tapers sweeps every profile + // edge into a parallelogram, which two triangles map exactly: a flat + // side is one quad, an uncut box's cap is one, and the path has its two + // ends alone. A path that twists or tapers, a circle and a flexible path + // keep the points they lay out. + TEST_CASE("llvolume: a straight path that neither twists nor tapers is one quad a profile edge", "[llvolume]") + { + Shape twisted = box(); + twisted.name = "twisted box"; + twisted.twist_begin = -0.5f; + twisted.twist_end = 0.5f; + Shape cone = cylinder(); + cone.name = "cone"; + cone.scale_x = cone.scale_y = 0.f; + const ExpectedCounts table[] = { + { box(), { { 24, 12 }, { 24, 12 }, { 24, 12 }, { 24, 12 } } }, + { cylinder(), { { 30, 24 }, { 42, 36 }, { 66, 60 }, { 102, 96 } } }, + { twisted, { { 32, 20 }, { 48, 36 }, { 126, 144 }, { 256, 348 } } }, + { cone, { { 30, 24 }, { 42, 36 }, { 82, 90 }, { 152, 192 } } }, + { torus(), { { 49, 72 }, { 100, 162 }, { 256, 450 }, { 625, 1152 } } }, + }; + for (const ExpectedCounts& expected : table) + { + checkCounts(expected); + } + + Report report; + for (const Shape& s : sweepShapes()) + { + if (s.path != LL_PCODE_PATH_LINE || s.twist_begin != s.twist_end || s.scale_x != 1.f || s.scale_y != 1.f) + { + continue; + } + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + LLPointer volume = build(s, lod); + const std::string name = lodName(s, lod); + if (volume->getPath().getPathLength() != 2) + { + report.fail(fmt::format("{}: {} path points", name, volume->getPath().getPathLength())); + continue; + } + // A hole drawn in to its wall is not straight between its + // corners, and keeps its split points; its outer sides + // keep theirs with it. + const bool drawn_in = volume->getProfile().holeTouchesWall(); + for (S32 i = 0; i < volume->getNumVolumeFaces(); ++i) + { + const LLVolumeFace& face = volume->getVolumeFace(i); + const LLProfile::Face& outline = volume->getProfile().mFaces[i]; + const bool flat_outer = (face.mTypeMask & LLVolumeFace::OUTER_MASK) && (face.mTypeMask & LLVolumeFace::FLAT_MASK); + if (flat_outer && !drawn_in && outline.mCount != 2) + { + report.fail(fmt::format("{}: {} has {} profile points", name, faceName(face), outline.mCount)); + } + if (isCap(face) && !(face.mTypeMask & (LLVolumeFace::HOLLOW_MASK | LLVolumeFace::OPEN_MASK)) && s.profile == LL_PCODE_PROFILE_SQUARE && + s.path_begin == 0.f && s.path_end == 1.f && face.mNumVertices != 4) + { + report.fail(fmt::format("{}: {} has {} vertices", name, faceName(face), face.mNumVertices)); + } + } + } + } + INFO(report.text()); + CHECK(report.failures == 0); + + // A square hole in a cylinder: its corners alone where it stays clear + // of the wall, and every split point where it is drawn in to it. + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + const S32 split = (S32)(LOD_DETAIL[lod] * 0.66f); + for (F32 hollow : { 0.5f, 0.85f }) + { + Shape s = cylinder(); + s.hole = LL_PCODE_HOLE_SQUARE; + s.hollow = hollow; + s.name = fmt::format("cylinder with a square hole at {}", hollow); + LLPointer volume = build(s, lod); + const LLProfile& outline = volume->getProfile(); + const S32 hole = outline.getTotal() - outline.getTotalOut(); + INFO(fmt::format("{}: {} hole points", lodName(s, lod), hole)); + CHECK(outline.holeTouchesWall() == (hollow > 0.75f)); + CHECK(hole == (outline.holeTouchesWall() ? 4 * (split + 1) + 1 : 5)); + CHECK(volume->getPath().getPathLength() == 2); + } + } + } } From b6af48a5ac7af88174d1fe6c28ec113a7b09684a Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 20 Oct 2026 17:29:43 -0400 Subject: [PATCH 2/4] A flat inner side copies its hole's corners and nothing else A square or triangle hole's inner side is one face, and createSide copied every profile point after the first so each strip got its own normals, split points included, plus a closing column. The copies at the hole's corners are its creases; the ones at split points made the inner side of a twisted or circular hollow prim faceted strip by strip while its outer sides are smooth, and each pair added a zero-width quad. The profile now records which points are the hole's corners, and the inner side copies those alone, so it is smooth from corner to corner as the outer sides are. The zero-width quads emit no triangles, decided from the column layout, never from positions, so flexible prims keep a topology fixed by their parameters. A hole pulled in to its wall counts every point as a corner. The hollow box is 52 / 32 at every LOD, a twisted hollow box at LOD 3 500 vertices instead of 612. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 74 +++++++--- indra/llmath/llvolume.h | 15 +- indra/llmath/tests/llvolume_test.cpp | 202 ++++++++++++++++++++++++--- 3 files changed, 249 insertions(+), 42 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index d576052804..6e34e4204a 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -450,7 +450,7 @@ S32 LLProfile::getNumNGonPoints(const LLProfileParams& params, S32 sides, F32 of // What is the bevel parameter used for? - DJS 04/05/02 // Bevel parameter is currently unused but presumedly would support // filleted and chamfered corners -void LLProfile::genNGon(const LLProfileParams& params, S32 sides, F32 offset, F32 bevel, F32 ang_scale, S32 split) +bool LLProfile::genNGon(const LLProfileParams& params, S32 sides, F32 offset, F32 bevel, F32 ang_scale, S32 split) { // Generate an n-sided "circular" path. // 0 is (1,0), and we go counter-clockwise along a circular path from there. @@ -491,7 +491,8 @@ void LLProfile::genNGon(const LLProfileParams& params, S32 sides, F32 offset, F3 t_fraction = (begin - t_first)*sides; // Only use if it's not almost exactly on an edge. - if (t_fraction < 0.9999f) + const bool begin_point = t_fraction < 0.9999f; + if (begin_point) { LLVector4a new_pt; new_pt.setLerp(pt1, pt2, t_fraction); @@ -575,6 +576,7 @@ void LLProfile::genNGon(const LLProfileParams& params, S32 sides, F32 offset, F3 } mTotal = mProfile.size(); + return begin_point; } // Hollow is percent of the original bounding box, not of this particular @@ -588,7 +590,7 @@ LLProfile::Face* LLProfile::addHole(const LLProfileParams& params, bool flat, F3 mTotalOut = mTotal; // Why is the "bevel" parameter -1? DJS 04/05/02 - genNGon(params, llfloor(sides),offset,-1, ang_scale, split); + const bool begin_point = genNGon(params, llfloor(sides),offset,-1, ang_scale, split); Face *face = addFace(mTotalOut, mTotal-mTotalOut,0,LL_FACE_INNER_SIDE, flat); @@ -670,6 +672,19 @@ LLProfile::Face* LLProfile::addHole(const LLProfileParams& params, bool flat, F3 mProfile[i] = pt[j--]; } + // A flat hole's corners. genNGon laid the points out as its begin point, + // where it has one, then split points and a corner by turns, split + // points first, to its end; they run the other way here. A hole drawn + // in to its wall may fold at any point. + if (flat) + { + const S32 count = mTotal - mTotalOut; + const S32 step = mHoleTouchesWall ? 1 : split + 1; + const S32 first_laid = (begin_point ? 1 : 0) + split; + face->mCornerStep = step; + face->mFirstCorner = ((count - 1 - first_laid) % step + step) % step; + } + for (S32 i=0;i<(S32)mFaces.size();i++) { if (mFaces[i].mCap) @@ -2979,11 +2994,16 @@ void LLVolume::createVolumeFaces() { type_mask |= LLVolumeFace::INNER_MASK; if (face.mFlat && num_s > 2) - { //flat inner faces have to copy vert normals - num_s = num_s*2; - if (num_s < 0) + { + // A flat inner side has a column for each of its + // points and a second for each corner, where its + // normals turn. + for (S32 point = 1; point + 1 < face.mCount; ++point) { - LL_ERRS() << "Volume face corruption detected." << LL_ENDL; + if (face.isCorner(point)) + { + ++num_s; + } } } } @@ -6633,9 +6653,10 @@ void LLVolumeFace::createTangents() std::fill(mTangents, mTangents + mNumVertices, tangent); } else if (!(mTypeMask & CAP_MASK) && mNumS > 0 && mNumT > 0 && mNumS * mNumT == mNumVertices && - mNumIndices == (mNumS - 1) * (mNumT - 1) * 6) + mNumIndices <= (mNumS - 1) * (mNumT - 1) * 6) { - // A side is createSide's grid. + // A side is createSide's grid. The quads and triangles its indices + // leave out have no area, and add nothing to a tangent. if (mFlexible) { // Along the rows, the way u runs across the face, and w as @@ -6905,8 +6926,16 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) S32 s, t; F32 ss, tt; + // A flat inner side copies each of its corners, so that its normals turn + // there: the quad between a corner and its copy has no width, and has + // no triangles. + const bool doubled = (mTypeMask & INNER_MASK) && (mTypeMask & FLAT_MASK) && mNumS > 2; + const LLProfile::Face* outline = doubled ? &volume->getProfile().mFaces[mID] : nullptr; + const S32 num_s = doubled ? outline->mCount : mNumS; + const S32 copies = mNumS - num_s; + num_vertices = mNumS*mNumT; - num_indices = (mNumS-1)*(mNumT-1)*6; + num_indices = (mNumS-1-copies)*(mNumT-1)*6; // createVolumeFaces builds partially only where the layout, and so the // counts, are the last build's. @@ -6925,21 +6954,19 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) LLVector4a* pos = (LLVector4a*) mPositions; LLVector2* tc = (LLVector2*) mTexCoords; F32 begin_stex = floorf(profile[mBeginS][2]); - S32 num_s = ((mTypeMask & INNER_MASK) && (mTypeMask & FLAT_MASK) && mNumS > 2) ? mNumS/2 : mNumS; S32 cur_vertex = 0; S32 end_t = mBeginT+mNumT; - bool test = (mTypeMask & INNER_MASK) && (mTypeMask & FLAT_MASK) && mNumS > 2; // Copy the vertices into the array. Every row takes the same columns of // its mesh row with the same s texture coordinates, and the path's t for // its own: the columns and their s are worked out once, so the rows are // copies. - { LL_PROFILE_ZONE_NAMED_CATEGORY_VOLUME("llvfcs - copy verts"); struct Column { S32 mesh; F32 s; + bool copy; // the copy of the column before it }; constexpr S32 STACK_COLUMNS = 256; Column stack_columns[STACK_COLUMNS]; @@ -6950,6 +6977,7 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) heap_columns.resize(mNumS); columns = heap_columns.data(); } + { LL_PROFILE_ZONE_NAMED_CATEGORY_VOLUME("llvfcs - copy verts"); S32 column_count = 0; for (s = 0; s < num_s; s++) { @@ -6990,17 +7018,12 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) // A column past the profile's end wraps to its start. const S32 column = mBeginS + s >= max_s ? mBeginS + s - max_s : mBeginS + s; - columns[column_count++] = { column, ss }; - if (test && s > 0) + columns[column_count++] = { column, ss, false }; + if (doubled && outline->isCorner(s)) { - columns[column_count++] = { column, ss }; + columns[column_count++] = { column, ss, true }; } } - if (test) - { - s = (mTypeMask & OPEN_MASK) ? num_s - 1 : 0; - columns[column_count++] = { mBeginS + s, profile[mBeginS + s][2] - begin_stex }; - } llassert(column_count == mNumS); const LLVector4a* mesh_points = mesh.data(); @@ -7067,6 +7090,10 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) { for (s = 0; s < (mNumS-1); s++) { + if (columns[s + 1].copy) + { + continue; + } mIndices[cur_index++] = s + mNumS*t; //bottom left mIndices[cur_index++] = s+1 + mNumS*(t+1); //top right mIndices[cur_index++] = s + mNumS*(t+1); //top left @@ -7075,6 +7102,7 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) mIndices[cur_index++] = s+1 + mNumS*(t+1); //top right } } + llassert(cur_index == mNumIndices); } // The normals come from the grid's quads, not from the triangles the @@ -7113,12 +7141,12 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) // one seam, and across its first and last rows: a prim's where they are // one place in every row or column, as its own positions say; a // sculpt's where its stitching samples them from one column or row of - // the map. + // the map. A flat inner side's seam is one of its corners. if (!partial_build) { if (sculpt_stitching == LL_SCULPT_TYPE_NONE) { - mJoinsS = columns_meet(pos, mNumS, mNumT, 0, mNumS - 1); + mJoinsS = !doubled && columns_meet(pos, mNumS, mNumT, 0, mNumS - 1); mJoinsT = rows_meet(pos, mNumS, 0, mNumT - 1); } else diff --git a/indra/llmath/llvolume.h b/indra/llmath/llvolume.h index d9fa739d9c..dcc6e41a12 100644 --- a/indra/llmath/llvolume.h +++ b/indra/llmath/llvolume.h @@ -721,6 +721,17 @@ class LLProfile bool mCap; bool mFlat; LLFaceID mFaceID; + // A flat hole's corners, where its side folds: of its points between + // its two ends, mFirstCorner and every mCornerStep-th after it. The + // rest are split points, on the straight edge between two corners. + // A step of 0 marks none. + S32 mFirstCorner = 0; + S32 mCornerStep = 0; + + bool isCorner(S32 point) const + { + return mCornerStep > 0 && point > 0 && point + 1 < mCount && point >= mFirstCorner && (point - mFirstCorner) % mCornerStep == 0; + } }; LLAlignedArray mProfile; @@ -736,7 +747,9 @@ class LLProfile ~LLProfile(); static S32 getNumNGonPoints(const LLProfileParams& params, S32 sides, F32 offset=0.0f, F32 bevel = 0.0f, F32 ang_scale = 1.f, S32 split = 0); - void genNGon(const LLProfileParams& params, S32 sides, F32 offset=0.0f, F32 bevel = 0.0f, F32 ang_scale = 1.f, S32 split = 0); + // Returns whether it began with the point of the begin cut, which it + // leaves out where the cut falls just short of a step. + bool genNGon(const LLProfileParams& params, S32 sides, F32 offset=0.0f, F32 bevel = 0.0f, F32 ang_scale = 1.f, S32 split = 0); Face* addHole(const LLProfileParams& params, bool flat, F32 sides, F32 offset, F32 box_hollow, F32 ang_scale, S32 split = 0); Face* addCap (S16 faceID); diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index 85687b8c1e..8f71c13d32 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -151,8 +151,8 @@ namespace return face.mTypeMask & LLVolumeFace::CAP_MASK; } - // A flat inner side copies every profile point, so that each is a hard - // edge: the quad between a point and its copy has no width. + // A flat inner side copies each of its corners, so that each is a hard + // edge: the quad between a corner and its copy has no width. bool isDoubled(const LLVolumeFace& face) { return (face.mTypeMask & LLVolumeFace::INNER_MASK) && (face.mTypeMask & LLVolumeFace::FLAT_MASK) && face.mNumS > 2; @@ -666,10 +666,43 @@ namespace if (face.mNumVertices != face.mNumS * face.mNumT) { fail(fmt::format("{} vertices for a grid of {} by {}", face.mNumVertices, face.mNumS, face.mNumT)); + return true; } - if (face.mNumIndices != (face.mNumS - 1) * (face.mNumT - 1) * 6) + // Each triangle is one of the grid's, bottom left, top right, + // top left or bottom left, bottom right, top right, a quad, and + // none twice; a triangle of the grid's left out has no area. + std::map, bool> grid; + for (S32 t = 0; t + 1 < face.mNumT; ++t) { - fail(fmt::format("{} indices for a grid of {} by {}", face.mNumIndices, face.mNumS, face.mNumT)); + for (S32 s = 0; s + 1 < face.mNumS; ++s) + { + const U32 bl = gridIndex(face, s, t), br = bl + 1, tl = gridIndex(face, s, t + 1), tr = tl + 1; + grid[{ bl, tr, tl }] = false; + grid[{ bl, br, tr }] = false; + } + } + for (S32 i = 0; i + 2 < face.mNumIndices; i += 3) + { + const auto found = grid.find({ face.mIndices[i], face.mIndices[i + 1], face.mIndices[i + 2] }); + if (found == grid.end() || found->second) + { + fail(fmt::format("triangle {} ({}, {}, {}) is {} of the grid's", i / 3, face.mIndices[i], face.mIndices[i + 1], face.mIndices[i + 2], + found == grid.end() ? "none" : "a second")); + return true; + } + found->second = true; + } + for (const auto& [corners, used] : grid) + { + const LLVector3 a = v3(face.mPositions[corners[0]]); + const LLVector3 b = v3(face.mPositions[corners[1]]); + const LLVector3 c = v3(face.mPositions[corners[2]]); + const F32 scale = (b - a).lengthSquared() + (c - a).lengthSquared() + (c - b).lengthSquared(); + if (!used && ((b - a) % (c - a)).length() > 1e-5f * scale) + { + fail(fmt::format("the grid's triangle ({}, {}, {}), which has area, is left out", corners[0], corners[1], corners[2])); + return true; + } } } return true; @@ -1001,17 +1034,27 @@ namespace } } - // A flat inner side is hard at every point. Its columns run p0, then - // each later point twice, then p0 again (or the last point, where the - // profile is cut), so the quads after even columns are the real ones - // and the quads after odd columns have no width: a vertex takes its - // normal from the one strip of real quads beside it, up and down the - // path, and from no other strip at its point. A vertex in no triangle - // with area, as the copies that close the side or an apex's copies, - // is beside no strip. - S32 stripOf(S32 column) + // A flat inner side is hard at every corner. Its columns run along its + // points, each corner twice, the copy one place with the corner in every + // row and with its UVs: the quad between them has no width. Each run of + // columns between copies is a strip, a flat facet along the path, and a + // vertex takes its normal from the triangles of its own strip at its + // point, and from no other strip there. A vertex in no triangle with + // area, as an apex's, is in no strip. + std::vector stripsOf(const LLVolumeFace& face) { - return column % 2 == 0 ? column : column - 1; + std::vector strip(face.mNumS, 0); + for (S32 s = 1; s < face.mNumS; ++s) + { + bool copy = true; + for (S32 t = 0; t < face.mNumT && copy; ++t) + { + copy = bitsOf(face.mPositions[gridIndex(face, s, t)]) == bitsOf(face.mPositions[gridIndex(face, s - 1, t)]) && + face.mTexCoords[gridIndex(face, s, t)] == face.mTexCoords[gridIndex(face, s - 1, t)]; + } + strip[s] = strip[s - 1] + (copy ? 1 : 0); + } + return strip; } void checkHardEdges(const LLVolumeFace& face, const std::vector& triangles, const std::vector& normals, Report& report, @@ -1023,6 +1066,7 @@ namespace } const std::vector lone = loneVertices(face, triangles); const std::vector groups = weldGroups(face); + const std::vector strips = stripsOf(face); std::map, std::vector> in_strip; for (size_t t = 0; t < triangles.size(); ++t) { @@ -1030,14 +1074,14 @@ namespace { continue; } - S32 strip = face.mNumS; + S32 column = face.mNumS; for (U32 v : triangles[t].v) { - strip = llmin(strip, (S32)(v % face.mNumS)); + column = llmin(column, (S32)(v % face.mNumS)); } for (U32 v : triangles[t].v) { - in_strip[{ groups[v], strip }].push_back((U32)t); + in_strip[{ groups[v], strips[column] }].push_back((U32)t); } } for (S32 v = 0; v < face.mNumVertices; ++v) @@ -1047,7 +1091,7 @@ namespace { continue; } - const auto found = in_strip.find({ groups[v], stripOf(column) }); + const auto found = in_strip.find({ groups[v], strips[column] }); if (found == in_strip.end()) { continue; @@ -3629,4 +3673,126 @@ namespace } } } + + // A flat inner side's columns, against its hole's points: each point a + // column, and a copy after each corner, a point where the hole turns, or + // after every point where the hole is drawn in to its wall; and no + // triangle in the quad between a corner and its copy. + void checkCorners(const LLVolume& volume, const std::string& name, Report& report) + { + const LLProfile& outline = volume.getProfile(); + for (S32 i = 0; i < volume.getNumVolumeFaces(); ++i) + { + const LLVolumeFace& face = volume.getVolumeFace(i); + if (!isDoubled(face) || !isGrid(face)) + { + continue; + } + const std::string where = fmt::format("{}: {}", name, faceName(face)); + const S32 points = outline.mFaces[i].mCount; + const std::vector strips = stripsOf(face); + std::vector copied(points, false); + S32 point = 0; + for (S32 s = 1; s < face.mNumS; ++s) + { + if (strips[s] != strips[s - 1]) + { + copied[point] = true; + } + else + { + ++point; + } + } + if (point != points - 1) + { + report.fail(fmt::format("{}: {} columns for {} points", where, face.mNumS, points)); + continue; + } + for (S32 k = 0; k < points; ++k) + { + bool turns = false; + if (k > 0 && k + 1 < points) + { + const LLVector4a& a = outline.mProfile[face.mBeginS + k - 1]; + const LLVector4a& b = outline.mProfile[face.mBeginS + k]; + const LLVector4a& c = outline.mProfile[face.mBeginS + k + 1]; + const F32 e1x = b[0] - a[0], e1y = b[1] - a[1], e2x = c[0] - b[0], e2y = c[1] - b[1]; + const F32 turn = e1x * e2y - e1y * e2x; + turns = fabsf(turn) > 1e-4f * sqrtf((e1x * e1x + e1y * e1y) * (e2x * e2x + e2y * e2y)) || outline.holeTouchesWall(); + } + if (copied[k] != turns) + { + report.fail(fmt::format("{}: point {} of {} {} but is {}", where, k, points, turns ? "turns" : "does not turn", + copied[k] ? "copied" : "not copied")); + break; + } + } + for (S32 t = 0; t + 2 < face.mNumIndices; t += 3) + { + S32 low = face.mNumS, high = 0; + for (S32 k = 0; k < 3; ++k) + { + low = llmin(low, (S32)(face.mIndices[t + k] % face.mNumS)); + high = llmax(high, (S32)(face.mIndices[t + k] % face.mNumS)); + } + if (strips[low] != strips[high]) + { + report.fail(fmt::format("{}: triangle {} spans a corner and its copy", where, t / 3)); + break; + } + } + } + } + + // A flat inner side copies its hole's corners, where its normals turn, + // and no point between them: a hollow box's inner side is one quad a + // side of its hole, as its outer sides are. + TEST_CASE("llvolume: a flat inner side copies its corners and nothing else", "[llvolume]") + { + Shape hollow_box = box(); + hollow_box.name = "hollow box"; + hollow_box.hole = LL_PCODE_HOLE_SQUARE; + hollow_box.hollow = 0.5f; + Shape hollow_prism = prism(); + hollow_prism.name = "hollow prism"; + hollow_prism.hollow = 0.5f; + checkCounts({ hollow_box, { { 52, 32 }, { 52, 32 }, { 52, 32 }, { 52, 32 } } }); + checkCounts({ hollow_prism, { { 40, 24 }, { 40, 24 }, { 40, 24 }, { 40, 24 } } }); + + Report report; + S32 volumes = 0; + for (const Shape& s : sweepShapes()) + { + if (s.hole != LL_PCODE_HOLE_SQUARE && s.hole != LL_PCODE_HOLE_TRIANGLE && !(s.hole == LL_PCODE_HOLE_SAME && s.profile != LL_PCODE_PROFILE_CIRCLE && + s.profile != LL_PCODE_PROFILE_CIRCLE_HALF)) + { + continue; + } + if (s.hollow <= 0.f) + { + continue; + } + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + LLPointer volume = build(s, lod); + checkCorners(*volume, lodName(s, lod), report); + ++volumes; + } + } + for (S32 sections : { 4, 16 }) + { + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + LLPointer volume = flexiVolume(LL_PCODE_PROFILE_SQUARE, LL_PCODE_HOLE_SQUARE, 0.5f, lod); + bendPath(*volume, sections, 1.1f); + volume->regen(); + checkCorners(*volume, fmt::format("flexible hollow box, {} sections, LOD {}", sections, lod), report); + } + } + INFO(fmt::format("{} volumes", volumes)); + INFO(report.text()); + CHECK(volumes > 0); + CHECK(report.failures == 0); + } } From d72584c04a8683b8300d65235eb96feb1d24feaa Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 20 Oct 2026 17:30:21 -0400 Subject: [PATCH 3/4] A shared volume emits no triangles where its parameters collapse a row or column A prim tapered to a point puts a whole row of vertices at its apex, and a sphere puts a whole column at each pole; half the triangles touching them have no area. A shared volume (not unique, not a sculpt) now emits none there. Which rows and columns collapse is decided from the parameters alone: a straight path scaled to 0 at an end it reaches, a circular path tapered by 1, and a half circle on a circle path with a hole size of 1 in y, no skew, no x taper and a constant twist in whole half turns. The vertices stay, since each column carries its own UV and normal, and no face is left empty. A sphere at LOD 3 is 325 / 528 instead of 325 / 576. The index loop is a template over whether a face has collapsed rows, so the common case runs the loop it always did. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 259 +++++++++++++++++++++------ indra/llmath/tests/llvolume_test.cpp | 247 ++++++++++++++++++++++--- 2 files changed, 430 insertions(+), 76 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index 6e34e4204a..9a16dcc8e1 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -2136,9 +2136,9 @@ bool LLVolume::generate() // edge into a parallelogram, and every face is flat or ruled straight // along the path: positions and UVs are affine over each quad, and the // split points and the rings between the ends lie on the triangles the - // ends make. A hole drawn in to its wall is the exception: its split - // points are drawn in too, where the edges they split are not, and - // they shape it. + // ends make. A hole drawn in to its wall is the exception: the wall + // draws in some of its points and not others, its edges no longer run + // straight between its corners, and its split points shape it. const S32 lighting_split = split; if (path_params.getCurveType() == LL_PCODE_PATH_LINE && !tapered && path_params.getTwistBegin() == path_params.getTwist()) { @@ -3005,6 +3005,10 @@ void LLVolume::createVolumeFaces() ++num_s; } } + if (num_s < 0) + { + LL_ERRS() << "Volume face corruption detected." << LL_ENDL; + } } } else @@ -5898,6 +5902,116 @@ namespace return outer_shorter(); } + // A side's rows and columns that its parameters take to one point at + // every station: the first or last row where the path tapers to nothing + // at an end it reaches, and the first or last column where a half circle + // ends on the axis a circle path turns it about and the path keeps it + // there, a sphere's poles. A half circle's ends lie on that axis where + // no cut moves them; a circle of no radius, a hole size of 1 in y, with + // no skew and no taper along the axis keeps them there, and a twist + // keeps them only by whole half turns. An inner side runs the other way + // along its profile. + struct Collapse + { + bool first_row = false; + bool last_row = false; + bool first_column = false; + bool last_column = false; + }; + + Collapse collapse_of(const LLVolume* volume, bool inner) + { + Collapse collapse; + const LLPathParams& path = volume->getParams().getPathParams(); + const U8 curve = path.getCurveType(); + if (curve == LL_PCODE_PATH_LINE) + { + // A line's scale of 2 narrows its beginning to nothing, as + // LLPathParams::getBeginScale has it, and one of 0 its end. + const F32 scale_x = path.getScaleX(); + const F32 scale_y = path.getScaleY(); + collapse.first_row = scale_x == 2.f && scale_y == 2.f && path.getBegin() == 0.f; + collapse.last_row = scale_x == 0.f && scale_y == 0.f && path.getEnd() == 1.f; + return collapse; + } + if (curve != LL_PCODE_PATH_CIRCLE && curve != LL_PCODE_PATH_CIRCLE2) + { + return collapse; + } + + // LLPath::genNGon tapers the end by a positive taper and the + // beginning by a negative one. + collapse.first_row = path.getTaperX() == -1.f && path.getTaperY() == -1.f && path.getBegin() == 0.f; + collapse.last_row = path.getTaperX() == 1.f && path.getTaperY() == 1.f && path.getEnd() == 1.f; + + const LLProfileParams& profile = volume->getParams().getProfileParams(); + const F32 half_turns = 2.f * path.getTwist(); + if (curve == LL_PCODE_PATH_CIRCLE && (profile.getCurveType() & LL_PCODE_PROFILE_MASK) == LL_PCODE_PROFILE_CIRCLE_HALF && + path.getScaleY() == 1.f && path.getSkew() == 0.f && path.getTaperX() == 0.f && path.getTwistBegin() == path.getTwist() && + half_turns == floorf(half_turns)) + { + const bool begin_pole = profile.getBegin() == 0.f; + const bool end_pole = profile.getEnd() == 1.f; + collapse.first_column = inner ? end_pole : begin_pole; + collapse.last_column = inner ? begin_pole : end_pole; + } + return collapse; + } + + // A side's triangles, from the quads of its grid, vertex s + num_s * t, + // whose left columns are listed, or from every quad: bottom left, top + // right, top left and bottom left, bottom right, top right, but for + // those whose corners a collapsed row or column puts in one place. + // Returns the end of what it wrote. + template + U16* grid_indices(U16* out, S32 num_s, S32 num_t, const S32* lefts, S32 quads, const Collapse& collapse) + { + const auto upper = [&](S32 s, S32 t) + { + *out++ = (U16)(s + num_s*t); //bottom left + *out++ = (U16)(s+1 + num_s*(t+1)); //top right + *out++ = (U16)(s + num_s*(t+1)); //top left + }; + const auto lower = [&](S32 s, S32 t) + { + *out++ = (U16)(s + num_s*t); //bottom left + *out++ = (U16)(s+1 + num_s*t); //bottom right + *out++ = (U16)(s+1 + num_s*(t+1)); //top right + }; + // The last quad's column. + const S32 last = num_s - 2; + for (S32 t = 0; t < num_t - 1; t++) + { + const bool keep_upper = !COLLAPSED || !(collapse.last_row && t + 1 == num_t - 1); + const bool keep_lower = !COLLAPSED || !(collapse.first_row && t == 0); + if (!COLLAPSED || (keep_upper && keep_lower && !collapse.first_column && !collapse.last_column)) + { + for (S32 q = 0; q < quads; ++q) + { + const S32 s = LISTED ? lefts[q] : q; + upper(s, t); + lower(s, t); + } + continue; + } + // A collapsed column is the first quad's left or the last + // quad's right. + for (S32 q = 0; q < quads; ++q) + { + const S32 s = LISTED ? lefts[q] : q; + if (keep_upper && !(s == 0 && collapse.first_column)) + { + upper(s, t); + } + if (keep_lower && !(s == last && collapse.last_column)) + { + lower(s, t); + } + } + } + return out; + } + // The path station a cap closes: the last for a top cap, the first for a // bottom one. S32 cap_station(const LLVolume* volume, bool top) @@ -6926,59 +7040,43 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) S32 s, t; F32 ss, tt; - // A flat inner side copies each of its corners, so that its normals turn - // there: the quad between a corner and its copy has no width, and has - // no triangles. + // A flat inner side copies each of its hole's corners, so that its + // normals turn there: the quad between a corner and its copy has no + // width, and has no triangles. const bool doubled = (mTypeMask & INNER_MASK) && (mTypeMask & FLAT_MASK) && mNumS > 2; const LLProfile::Face* outline = doubled ? &volume->getProfile().mFaces[mID] : nullptr; const S32 num_s = doubled ? outline->mCount : mNumS; - const S32 copies = mNumS - num_s; - - num_vertices = mNumS*mNumT; - num_indices = (mNumS-1-copies)*(mNumT-1)*6; - - // createVolumeFaces builds partially only where the layout, and so the - // counts, are the last build's. - llassert(!partial_build || (num_vertices == mNumVertices && num_indices == mNumIndices)); - if (num_vertices != mNumVertices || num_indices != mNumIndices) - { - partial_build = false; - } - - if (!partial_build) - { - resizeVertices(num_vertices); - resizeIndices(num_indices); - } - LLVector4a* pos = (LLVector4a*) mPositions; - LLVector2* tc = (LLVector2*) mTexCoords; F32 begin_stex = floorf(profile[mBeginS][2]); - S32 cur_vertex = 0; - S32 end_t = mBeginT+mNumT; - - // Copy the vertices into the array. Every row takes the same columns of - // its mesh row with the same s texture coordinates, and the path's t for - // its own: the columns and their s are worked out once, so the rows are - // copies. + // Every row takes the same columns of its mesh row with the same s + // texture coordinates, and the path's t for its own: the columns and + // their s are worked out once, so the rows are copies. struct Column { S32 mesh; F32 s; - bool copy; // the copy of the column before it }; constexpr S32 STACK_COLUMNS = 256; Column stack_columns[STACK_COLUMNS]; std::vector heap_columns; Column* columns = stack_columns; + // A flat inner side's quads with area, by their left columns. + S32 stack_lefts[STACK_COLUMNS]; + std::vector heap_lefts; + S32* lefts = stack_lefts; if (mNumS > STACK_COLUMNS) { heap_columns.resize(mNumS); columns = heap_columns.data(); + if (doubled) + { + heap_lefts.resize(mNumS); + lefts = heap_lefts.data(); + } } - { LL_PROFILE_ZONE_NAMED_CATEGORY_VOLUME("llvfcs - copy verts"); S32 column_count = 0; + S32 quads = doubled ? 0 : mNumS - 1; for (s = 0; s < num_s; s++) { if (mTypeMask & END_MASK) @@ -7018,14 +7116,72 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) // A column past the profile's end wraps to its start. const S32 column = mBeginS + s >= max_s ? mBeginS + s - max_s : mBeginS + s; - columns[column_count++] = { column, ss, false }; + if (doubled && s > 0) + { + lefts[quads++] = column_count - 1; + } + columns[column_count++] = { column, ss }; if (doubled && outline->isCorner(s)) { - columns[column_count++] = { column, ss, true }; + columns[column_count++] = { column, ss }; } } llassert(column_count == mNumS); + const S32 rows = mNumT - 1; + + // On a shared volume, a row or column its parameters take to one point + // keeps its vertices, each with its own UV and normal, and has no + // triangles: those with two corners there have no area. Of a grid + // quad's two triangles, bottom left, top right, top left has two corners + // on its left column and on its top row, and bottom left, bottom right, + // top right on its right column and its bottom row. A face that would be + // left with none keeps them all. + Collapse collapse; + if (!volume->isUnique() && sculpt_stitching == LL_SCULPT_TYPE_NONE) + { + collapse = collapse_of(volume, mTypeMask & INNER_MASK); + if (mTypeMask & END_MASK) + { + collapse.first_column = false; + collapse.last_column = false; + } + } + const S32 upper_left_out = (collapse.first_column ? rows : 0) + (collapse.last_row ? quads : 0) - (collapse.first_column && collapse.last_row ? 1 : 0); + const S32 lower_left_out = (collapse.last_column ? rows : 0) + (collapse.first_row ? quads : 0) - (collapse.last_column && collapse.first_row ? 1 : 0); + S32 triangles = 2 * quads * rows - upper_left_out - lower_left_out; + if (triangles <= 0) + { + collapse = Collapse(); + triangles = 2 * quads * rows; + } + const bool collapsed = collapse.first_row || collapse.last_row || collapse.first_column || collapse.last_column; + + num_vertices = mNumS*mNumT; + num_indices = triangles*3; + + // createVolumeFaces builds partially only where the layout, and so the + // counts, are the last build's. + llassert(!partial_build || (num_vertices == mNumVertices && num_indices == mNumIndices)); + if (num_vertices != mNumVertices || num_indices != mNumIndices) + { + partial_build = false; + } + + if (!partial_build) + { + resizeVertices(num_vertices); + resizeIndices(num_indices); + } + + LLVector4a* pos = (LLVector4a*) mPositions; + LLVector2* tc = (LLVector2*) mTexCoords; + + S32 cur_vertex = 0; + S32 end_t = mBeginT+mNumT; + + // Copy the vertices into the array. + { LL_PROFILE_ZONE_NAMED_CATEGORY_VOLUME("llvfcs - copy verts"); const LLVector4a* mesh_points = mesh.data(); for (t = mBeginT; t < end_t; t++) { @@ -7086,22 +7242,18 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) LL_PROFILE_ZONE_NAMED_CATEGORY_VOLUME("llvfcs - generate indices"); // Now we generate the indices. - for (t = 0; t < (mNumT-1); t++) + U16* end_index = mIndices; + if (collapsed) { - for (s = 0; s < (mNumS-1); s++) - { - if (columns[s + 1].copy) - { - continue; - } - mIndices[cur_index++] = s + mNumS*t; //bottom left - mIndices[cur_index++] = s+1 + mNumS*(t+1); //top right - mIndices[cur_index++] = s + mNumS*(t+1); //top left - mIndices[cur_index++] = s + mNumS*t; //bottom left - mIndices[cur_index++] = s+1 + mNumS*t; //bottom right - mIndices[cur_index++] = s+1 + mNumS*(t+1); //top right - } + end_index = doubled ? grid_indices(mIndices, mNumS, mNumT, lefts, quads, collapse) + : grid_indices(mIndices, mNumS, mNumT, lefts, quads, collapse); + } + else + { + end_index = doubled ? grid_indices(mIndices, mNumS, mNumT, lefts, quads, collapse) + : grid_indices(mIndices, mNumS, mNumT, lefts, quads, collapse); } + cur_index = (S32)(end_index - mIndices); llassert(cur_index == mNumIndices); } @@ -7215,7 +7367,8 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) // On a sphere's side, a column that is one place in every row is a pole // on the axis the half circle sweeps about, and takes the axis. A cut - // end of a hollow sphere lies on the axis, and has no area. + // end of a hollow sphere lies on the axis, and has no area. A flat inner + // side's last point keeps the normal of the strip it ends. if (sculpt_stitching == LL_SCULPT_TYPE_NONE && !(mTypeMask & END_MASK) && volume->getPathType() == LL_PCODE_PATH_CIRCLE && ((volume->getProfileType() & LL_PCODE_PROFILE_MASK) == LL_PCODE_PROFILE_CIRCLE_HALF)) { @@ -7227,7 +7380,7 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) } } - if (column_is_point(pos, mNumS, mNumT, mNumS - 1)) + if (!doubled && column_is_point(pos, mNumS, mNumT, mNumS - 1)) { //all upper S have same normal for (S32 i = 0; i < mNumT; i++) { diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index 8f71c13d32..4c62ac636a 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -151,8 +151,8 @@ namespace return face.mTypeMask & LLVolumeFace::CAP_MASK; } - // A flat inner side copies each of its corners, so that each is a hard - // edge: the quad between a corner and its copy has no width. + // A flat inner side copies each of its hole's corners, so that each is a + // hard edge: the quad between a corner and its copy has no width. bool isDoubled(const LLVolumeFace& face) { return (face.mTypeMask & LLVolumeFace::INNER_MASK) && (face.mTypeMask & LLVolumeFace::FLAT_MASK) && face.mNumS > 2; @@ -961,10 +961,22 @@ namespace incident[groups[v]].push_back((U32)t); } } + // The oracle gives a normal only to a vertex a triangle uses: of the + // vertices at a point the parameters collapse, as an apex's or a + // pole's, the triangles of no area are left out, and some vertices + // with them. + std::vector used(face.mNumVertices, false); + for (const Triangle& tri : triangles) + { + for (U32 v : tri.v) + { + used[v] = true; + } + } for (S32 v = 0; v < face.mNumVertices; ++v) { const std::vector& around = incident[groups[v]]; - if (around.empty() || std::any_of(around.begin(), around.end(), [&](U32 t) { return triangles[t].degenerate; })) + if (!used[v] || around.empty() || std::any_of(around.begin(), around.end(), [&](U32 t) { return triangles[t].degenerate; })) { continue; } @@ -1037,7 +1049,7 @@ namespace // A flat inner side is hard at every corner. Its columns run along its // points, each corner twice, the copy one place with the corner in every // row and with its UVs: the quad between them has no width. Each run of - // columns between copies is a strip, a flat facet along the path, and a + // columns between copies is a strip, a facet along the path, and a // vertex takes its normal from the triangles of its own strip at its // point, and from no other strip there. A vertex in no triangle with // area, as an apex's, is in no strip. @@ -2581,7 +2593,7 @@ namespace const Bound bounds[] = { { "box", { 2.0000, 2.0000, 2.0000, 2.0000 }, { 1.2000, 1.2000, 1.2000, 1.2000 } }, { "cylinder", { 1.2500, 1.1667, 1.1000, 1.0833 }, { 1.0133, 1.0286, 1.0182, 1.0353 } }, - { "sphere", { 0.7778, 0.6944, 0.6429, 1.0833 }, { 1.0286, 1.0240, 1.0000, 1.0043 } }, + { "sphere", { 1.0833, 0.8889, 0.7000, 1.0909 }, { 1.0286, 1.0240, 1.0000, 1.0043 } }, { "torus", { 0.6806, 1.1111, 1.0667, 1.0417 }, { 1.0122, 1.0080, 1.0000, 1.0010 } }, { "sculpt sphere", { 0.6806, 1.1250, 1.0625, 1.0312 }, { 1.0122, 1.0074, 1.0021, 1.0006 } }, { "sculpt plane", { 0.6806, 1.1250, 1.0625, 1.0312 }, { 1.0122, 1.0074, 1.0021, 1.0006 } }, @@ -3597,14 +3609,14 @@ namespace twisted.name = "twisted box"; twisted.twist_begin = -0.5f; twisted.twist_end = 0.5f; - Shape cone = cylinder(); - cone.name = "cone"; - cone.scale_x = cone.scale_y = 0.f; + Shape tapered = cylinder(); + tapered.name = "tapered cylinder"; + tapered.scale_x = tapered.scale_y = 0.5f; const ExpectedCounts table[] = { { box(), { { 24, 12 }, { 24, 12 }, { 24, 12 }, { 24, 12 } } }, { cylinder(), { { 30, 24 }, { 42, 36 }, { 66, 60 }, { 102, 96 } } }, { twisted, { { 32, 20 }, { 48, 36 }, { 126, 144 }, { 256, 348 } } }, - { cone, { { 30, 24 }, { 42, 36 }, { 82, 90 }, { 152, 192 } } }, + { tapered, { { 30, 24 }, { 42, 36 }, { 82, 90 }, { 152, 192 } } }, { torus(), { { 49, 72 }, { 100, 162 }, { 256, 450 }, { 625, 1152 } } }, }; for (const ExpectedCounts& expected : table) @@ -3674,10 +3686,12 @@ namespace } } - // A flat inner side's columns, against its hole's points: each point a - // column, and a copy after each corner, a point where the hole turns, or - // after every point where the hole is drawn in to its wall; and no - // triangle in the quad between a corner and its copy. + + // A flat inner side's columns, against its hole's points: a column for + // each, and a copy after each corner, a point where the hole turns, or + // after every point between the ends where the hole is drawn in to its + // wall; no copy of an end or of a split point; and no triangle in the + // quad between a corner and its copy. void checkCorners(const LLVolume& volume, const std::string& name, Report& report) { const LLProfile& outline = volume.getProfile(); @@ -3691,13 +3705,13 @@ namespace const std::string where = fmt::format("{}: {}", name, faceName(face)); const S32 points = outline.mFaces[i].mCount; const std::vector strips = stripsOf(face); - std::vector copied(points, false); + std::vector copies(points, 0); S32 point = 0; for (S32 s = 1; s < face.mNumS; ++s) { if (strips[s] != strips[s - 1]) { - copied[point] = true; + ++copies[point]; } else { @@ -3711,20 +3725,18 @@ namespace } for (S32 k = 0; k < points; ++k) { - bool turns = false; + bool corner = false; if (k > 0 && k + 1 < points) { const LLVector4a& a = outline.mProfile[face.mBeginS + k - 1]; const LLVector4a& b = outline.mProfile[face.mBeginS + k]; const LLVector4a& c = outline.mProfile[face.mBeginS + k + 1]; const F32 e1x = b[0] - a[0], e1y = b[1] - a[1], e2x = c[0] - b[0], e2y = c[1] - b[1]; - const F32 turn = e1x * e2y - e1y * e2x; - turns = fabsf(turn) > 1e-4f * sqrtf((e1x * e1x + e1y * e1y) * (e2x * e2x + e2y * e2y)) || outline.holeTouchesWall(); + corner = fabsf(e1x * e2y - e1y * e2x) > 1e-4f * sqrtf((e1x * e1x + e1y * e1y) * (e2x * e2x + e2y * e2y)) || outline.holeTouchesWall(); } - if (copied[k] != turns) + if (copies[k] != (corner ? 1 : 0)) { - report.fail(fmt::format("{}: point {} of {} {} but is {}", where, k, points, turns ? "turns" : "does not turn", - copied[k] ? "copied" : "not copied")); + report.fail(fmt::format("{}: point {} of {}, {}, has {} copies", where, k, points, corner ? "a corner" : "no corner", copies[k])); break; } } @@ -3746,8 +3758,9 @@ namespace } // A flat inner side copies its hole's corners, where its normals turn, - // and no point between them: a hollow box's inner side is one quad a - // side of its hole, as its outer sides are. + // and no point between them, so that it is smooth from corner to corner + // as an outer side is; it has no triangles between a corner and its + // copy. A hollow box's inner side is one quad a side of its hole. TEST_CASE("llvolume: a flat inner side copies its corners and nothing else", "[llvolume]") { Shape hollow_box = box(); @@ -3757,8 +3770,37 @@ namespace Shape hollow_prism = prism(); hollow_prism.name = "hollow prism"; hollow_prism.hollow = 0.5f; + Shape twisted = hollow_box; + twisted.name = "twisted hollow box"; + twisted.twist_begin = -0.5f; + twisted.twist_end = 0.5f; + Shape tube_hole = tube(); + tube_hole.name = "tube with a square hole"; + tube_hole.hole = LL_PCODE_HOLE_SQUARE; + tube_hole.hollow = 0.5f; checkCounts({ hollow_box, { { 52, 32 }, { 52, 32 }, { 52, 32 }, { 52, 32 } } }); checkCounts({ hollow_prism, { { 40, 24 }, { 40, 24 }, { 40, 24 }, { 40, 24 } } }); + checkCounts({ twisted, { { 68, 48 }, { 100, 80 }, { 252, 288 }, { 500, 672 } } }); + checkCounts({ tube_hole, { { 112, 96 }, { 160, 144 }, { 384, 480 }, { 800, 1152 } } }); + + // A flexible hollow box, as a frame lays it out: its path's sections + // keep their split points, and its inner side copies only corners. + struct Flexi + { + S32 sections; + S32 lod; + Counts counts; + }; + for (const Flexi& expected : { Flexi{ 4, 1, { 100, 80 } }, Flexi{ 4, 3, { 212, 240 } }, Flexi{ 16, 1, { 292, 272 } }, Flexi{ 16, 3, { 596, 816 } } }) + { + LLPointer volume = flexiVolume(LL_PCODE_PROFILE_SQUARE, LL_PCODE_HOLE_SQUARE, 0.5f, expected.lod); + bendPath(*volume, expected.sections, 1.1f); + volume->regen(); + const Counts counts = countsOf(*volume); + INFO(fmt::format("flexible hollow box, {} sections, LOD {}: {} vertices and {} triangles, where {} and {} are expected", expected.sections, + expected.lod, counts.vertices, counts.triangles, expected.counts.vertices, expected.counts.triangles)); + CHECK(counts == expected.counts); + } Report report; S32 volumes = 0; @@ -3790,9 +3832,168 @@ namespace checkCorners(*volume, fmt::format("flexible hollow box, {} sections, LOD {}", sections, lod), report); } } + // A hollow sphere's flat inner side runs from pole to pole; its + // last point takes the normal the strip it ends gives it, not the + // axis. + for (U8 hole : { LL_PCODE_HOLE_SQUARE, LL_PCODE_HOLE_TRIANGLE }) + { + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + Shape s = sphere(); + s.hole = hole; + s.hollow = 0.75f; + s.name = hole == LL_PCODE_HOLE_SQUARE ? "sphere with a square hole" : "sphere with a triangle hole"; + LLPointer volume = build(s, lod); + for (S32 i = 0; i < volume->getNumVolumeFaces(); ++i) + { + const LLVolumeFace& face = volume->getVolumeFace(i); + if (!isDoubled(face) || !isGrid(face)) + { + continue; + } + for (S32 column : { face.mNumS - 1 }) + { + for (S32 t = 0; t < face.mNumT; ++t) + { + const LLVector3 n = v3(face.mNormals[gridIndex(face, column, t)]); + if (degreesBetween(n, LLVector3(1.f, 0.f, 0.f)) < 1.f || degreesBetween(n, LLVector3(-1.f, 0.f, 0.f)) < 1.f) + { + report.fail(fmt::format("{}: {} column {} row {} takes the axis", lodName(s, lod), faceName(face), column, t)); + break; + } + } + } + } + } + } INFO(fmt::format("{} volumes", volumes)); INFO(report.text()); CHECK(volumes > 0); CHECK(report.failures == 0); } + + + // A shared volume's faces against its unique twin's: the same vertices, + // and the same triangles but for some of no area, which it leaves out. + // Returns how many it left out. + S32 checkAgainstUnique(const Shape& s, S32 lod, Report& report) + { + LLPointer shared = build(s, lod); + LLPointer unique = build(s, lod, true); + const std::string name = lodName(s, lod); + if (shared->getNumVolumeFaces() != unique->getNumVolumeFaces()) + { + report.fail(fmt::format("{}: {} faces against {}", name, shared->getNumVolumeFaces(), unique->getNumVolumeFaces())); + return 0; + } + S32 left_out = 0; + for (S32 i = 0; i < shared->getNumVolumeFaces(); ++i) + { + const LLVolumeFace& a = shared->getVolumeFace(i); + const LLVolumeFace& b = unique->getVolumeFace(i); + const std::string where = fmt::format("{}: {}", name, faceName(a)); + if (a.mNumVertices != b.mNumVertices || std::memcmp(a.mPositions, b.mPositions, sizeof(LLVector4a) * a.mNumVertices) != 0 || + std::memcmp(a.mTexCoords, b.mTexCoords, sizeof(LLVector2) * a.mNumVertices) != 0) + { + report.fail(fmt::format("{}: its vertices are not its unique twin's", where)); + continue; + } + std::map, S32> kept; + for (S32 k = 0; k + 2 < a.mNumIndices; k += 3) + { + ++kept[{ a.mIndices[k], a.mIndices[k + 1], a.mIndices[k + 2] }]; + } + const std::vector triangles = trianglesOf(b); + for (const Triangle& tri : triangles) + { + const auto found = kept.find({ (U16)tri.v[0], (U16)tri.v[1], (U16)tri.v[2] }); + if (found != kept.end() && found->second > 0) + { + --found->second; + continue; + } + ++left_out; + if (!tri.degenerate) + { + report.fail(fmt::format("{}: triangle ({}, {}, {}), which has area, is left out", where, tri.v[0], tri.v[1], tri.v[2])); + return left_out; + } + } + // Every triangle it kept is one of the twin's. + for (const auto& [corners, count] : kept) + { + if (count != 0) + { + report.fail(fmt::format("{}: triangle ({}, {}, {}) is not its unique twin's", where, corners[0], corners[1], corners[2])); + return left_out; + } + } + } + return left_out; + } + + // On a shared volume, a row or column its parameters take to one point, + // the apex of a taper to a point or a sphere's pole, has no triangles: + // those with two corners there have no area. It keeps its vertices, + // each with its own UV and normal. The parameters decide it, so a + // unique volume, whose path a rebuild may move, keeps every triangle, + // and so does a sphere whose twist turns its poles off the axis. + TEST_CASE("llvolume: a shared volume has no triangles where its parameters collapse a row or column", "[llvolume]") + { + Shape pyramid = box(); + pyramid.name = "pyramid"; + pyramid.scale_x = pyramid.scale_y = 0.f; + Shape inverted = box(); + inverted.name = "box tapered to a point at its base"; + inverted.scale_x = inverted.scale_y = 2.f; + Shape cone = cylinder(); + cone.name = "cone"; + cone.scale_x = cone.scale_y = 0.f; + Shape half_turned = sphere(); + half_turned.name = "sphere turned half a turn"; + half_turned.twist_begin = half_turned.twist_end = 0.5f; + checkCounts({ sphere(), { { 28, 24 }, { 50, 54 }, { 128, 180 }, { 325, 528 } } }); + checkCounts({ half_turned, { { 28, 24 }, { 50, 54 }, { 128, 180 }, { 325, 528 } } }); + checkCounts({ pyramid, { { 24, 8 }, { 24, 8 }, { 24, 8 }, { 24, 8 } } }); + checkCounts({ inverted, { { 24, 8 }, { 24, 8 }, { 24, 8 }, { 24, 8 } } }); + checkCounts({ cone, { { 30, 18 }, { 42, 27 }, { 82, 75 }, { 152, 168 } } }); + + Report report; + Shape twisted = sphere(); + twisted.name = "twisted sphere"; + twisted.twist_begin = -0.5f; + twisted.twist_end = 0.75f; + Shape quarter_turned = sphere(); + quarter_turned.name = "sphere turned a quarter turn"; + quarter_turned.twist_begin = quarter_turned.twist_end = 0.25f; + for (const Shape& s : { twisted, quarter_turned }) + { + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + if (checkAgainstUnique(s, lod, report) != 0) + { + report.fail(fmt::format("{}: triangles left out where the poles leave the axis", lodName(s, lod))); + } + } + } + S32 left_out = 0; + for (const Shape& s : sweepShapes()) + { + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + left_out += checkAgainstUnique(s, lod, report); + } + } + for (const Shape& s : { pyramid, inverted, cone, half_turned }) + { + for (S32 lod = 0; lod < LOD_COUNT; ++lod) + { + left_out += checkAgainstUnique(s, lod, report); + } + } + INFO(fmt::format("{} triangles left out", left_out)); + INFO(report.text()); + CHECK(left_out > 0); + CHECK(report.failures == 0); + } } From 5eb8b960fdea9ca1874ef5660e852ef9822cd1c2 Mon Sep 17 00:00:00 2001 From: Rye Date: Tue, 20 Oct 2026 17:30:54 -0400 Subject: [PATCH 4/4] A hollow sphere's inner side takes its strip's normal at both ends The sphere pole override gave the first point of a hollow sphere's flat inner side the axis as its normal, while the last point kept its strip's, which shaded half of that side with a 45-degree gradient. Both ends now keep their strip's normal. Co-Authored-By: Claude Opus 5.5 --- indra/llmath/llvolume.cpp | 7 ++++--- indra/llmath/tests/llvolume_test.cpp | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/indra/llmath/llvolume.cpp b/indra/llmath/llvolume.cpp index 9a16dcc8e1..a7331aa24b 100644 --- a/indra/llmath/llvolume.cpp +++ b/indra/llmath/llvolume.cpp @@ -7368,8 +7368,9 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) // On a sphere's side, a column that is one place in every row is a pole // on the axis the half circle sweeps about, and takes the axis. A cut // end of a hollow sphere lies on the axis, and has no area. A flat inner - // side's last point keeps the normal of the strip it ends. - if (sculpt_stitching == LL_SCULPT_TYPE_NONE && !(mTypeMask & END_MASK) && volume->getPathType() == LL_PCODE_PATH_CIRCLE && + // side is a cone at a pole, and its ends keep the normals of the strips + // they end. + if (sculpt_stitching == LL_SCULPT_TYPE_NONE && !(mTypeMask & END_MASK) && !doubled && volume->getPathType() == LL_PCODE_PATH_CIRCLE && ((volume->getProfileType() & LL_PCODE_PROFILE_MASK) == LL_PCODE_PROFILE_CIRCLE_HALF)) { if (column_is_point(pos, mNumS, mNumT, 0)) @@ -7380,7 +7381,7 @@ bool LLVolumeFace::createSide(LLVolume* volume, bool partial_build) } } - if (!doubled && column_is_point(pos, mNumS, mNumT, mNumS - 1)) + if (column_is_point(pos, mNumS, mNumT, mNumS - 1)) { //all upper S have same normal for (S32 i = 0; i < mNumT; i++) { diff --git a/indra/llmath/tests/llvolume_test.cpp b/indra/llmath/tests/llvolume_test.cpp index 4c62ac636a..37afec0356 100644 --- a/indra/llmath/tests/llvolume_test.cpp +++ b/indra/llmath/tests/llvolume_test.cpp @@ -3832,9 +3832,9 @@ namespace checkCorners(*volume, fmt::format("flexible hollow box, {} sections, LOD {}", sections, lod), report); } } - // A hollow sphere's flat inner side runs from pole to pole; its - // last point takes the normal the strip it ends gives it, not the - // axis. + // A hollow sphere's flat inner side runs from pole to pole, a cone + // at each: both its ends take the normals the strips they end give + // them, not the axis. for (U8 hole : { LL_PCODE_HOLE_SQUARE, LL_PCODE_HOLE_TRIANGLE }) { for (S32 lod = 0; lod < LOD_COUNT; ++lod) @@ -3851,7 +3851,7 @@ namespace { continue; } - for (S32 column : { face.mNumS - 1 }) + for (S32 column : { 0, face.mNumS - 1 }) { for (S32 t = 0; t < face.mNumT; ++t) {