Add texture projection frame overlay, fix remeshing and subdivision

This commit is contained in:
ExPikaPaka
2026-07-21 09:44:25 +02:00
parent 8247514ae2
commit ab023f3f6d
24 changed files with 4658 additions and 120 deletions
+46 -2
View File
@@ -29,6 +29,7 @@
// For parameterize_lscm()
#include <CGAL/Polygon_mesh_processing/border.h>
#include <CGAL/Polygon_mesh_processing/connected_components.h>
#include <CGAL/Polygon_mesh_processing/detect_features.h>
#include <CGAL/Surface_mesh_parameterization/Error_code.h>
#include <CGAL/Surface_mesh_parameterization/LSCM_parameterizer_3.h>
#include <CGAL/Surface_mesh_parameterization/parameterize.h>
@@ -259,7 +260,8 @@ indexed_triangle_set cgal_to_indexed_triangle_set(const CGALMesh &cgalmesh)
// Isotropic remeshing
// /////////////////////////////////////////////////////////////////////////////
indexed_triangle_set remesh_isotropic(const indexed_triangle_set &mesh, double target_edge_length, unsigned n_iterations)
indexed_triangle_set remesh_isotropic(const indexed_triangle_set &mesh, double target_edge_length,
unsigned n_iterations, double sharp_angle_deg)
{
if (mesh.indices.empty() || target_edge_length <= 0.0)
return mesh;
@@ -269,14 +271,56 @@ indexed_triangle_set remesh_isotropic(const indexed_triangle_set &mesh, double t
if (cgal_mesh.is_empty() || cgal_mesh.number_of_faces() == 0)
return mesh;
// Surface_mesh::add_face() refuses any face that would make the mesh non-manifold and returns a
// null descriptor instead. Remeshing a mesh that silently lost faces that way produces holes in
// the output, so bail out and let the caller report it rather than hand back a punctured model.
if (cgal_mesh.number_of_faces() != mesh.indices.size())
return mesh;
using edge_descriptor = boost::graph_traits<_EpicMesh>::edge_descriptor;
try {
// Sharp edges and open borders are pinned before remeshing. Without that, the tangential
// relaxation pass slides vertices along the surface and rounds every hard feature off - a
// cube comes back with wobbly, eroded edges, which is the most visible way "remeshing does
// not work properly". protect_constraints() forbids splitting or collapsing them, but it
// requires each constrained edge to already be shorter than 4/3 * target, hence the split
// first (passing the map so the halves inherit the constraint). This mirrors CGAL's own
// isotropic_remeshing example.
auto ecm = cgal_mesh.add_property_map<edge_descriptor, bool>("e:is_constrained", false).first;
if (sharp_angle_deg > 0.0)
CGALProc::detect_sharp_edges(cgal_mesh, sharp_angle_deg, ecm);
for (edge_descriptor e : edges(cgal_mesh)) {
const auto h = halfedge(e, cgal_mesh);
if (is_border(h, cgal_mesh) || is_border(opposite(h, cgal_mesh), cgal_mesh))
put(ecm, e, true);
}
std::vector<edge_descriptor> constrained;
for (edge_descriptor e : edges(cgal_mesh))
if (get(ecm, e))
constrained.push_back(e);
if (!constrained.empty())
CGALProc::split_long_edges(constrained, target_edge_length, cgal_mesh,
CGALParams::edge_is_constrained_map(ecm));
CGALProc::isotropic_remeshing(faces(cgal_mesh), target_edge_length, cgal_mesh,
CGALParams::number_of_iterations(n_iterations));
CGALParams::number_of_iterations(n_iterations)
.edge_is_constrained_map(ecm)
.protect_constraints(true));
} catch (const std::exception &) {
return mesh; // CGAL throws on some non-manifold / degenerate inputs; leave the mesh untouched
}
if (cgal_mesh.number_of_faces() == 0)
return mesh;
// isotropic_remeshing edits in place, and its edge collapses only *mark* vertices and faces as
// removed - the underlying arrays keep the holes until the garbage is collected. That matters
// because cgal_to_indexed_triangle_set() numbers its output vertices by iteration order (which
// skips removed slots) while reading each face's corner as the raw integer value of the vertex
// descriptor (which does not). Past the first collapse the two disagree, so every triangle
// points at the wrong vertices, and any descriptor beyond the live vertex count is dropped
// together with its triangle. Compacting first makes descriptor == iteration order again.
cgal_mesh.collect_garbage();
return cgal_to_indexed_triangle_set(cgal_mesh);
}
+3 -1
View File
@@ -86,9 +86,11 @@ std::optional<std::vector<Vec2f>> parameterize_lscm(const indexed_triangle_set &
// Isotropic remeshing (CGAL): rebuilds the mesh so its triangles are close to a uniform target edge
// length, splitting oversized triangles and collapsing undersized ones. Used to even out a model with
// wildly varying triangle sizes so texture displacement has a consistent vertex density to work with.
// Edges whose dihedral angle exceeds `sharp_angle_deg`, and any open border, are held fixed so hard
// features survive instead of being eroded by the relaxation pass; pass 0 to remesh everything.
// Returns the input unchanged if remeshing fails (e.g. a non-manifold or self-intersecting input).
indexed_triangle_set remesh_isotropic(const indexed_triangle_set &mesh, double target_edge_length,
unsigned n_iterations = 3);
unsigned n_iterations = 3, double sharp_angle_deg = 40.0);
}
namespace mcut {
+31 -2
View File
@@ -934,6 +934,19 @@ float blend_displacement(float accumulated, float value, TextureBlendMode mode)
}
}
bool project_uv_projective(const std::array<float, 12> &m, const Vec3f &position, Vec2f &uv)
{
const float x = position.x(), y = position.y(), z = position.z();
const float w = m[8] * x + m[9] * y + m[10] * z + m[11];
// Strictly greater than zero: at w == 0 the point sits on the projector's plane and maps to
// infinity, and at w < 0 it is behind the projector, where dividing yields a plausible-looking
// but mirrored uv - the classic way a projected decal reappears on the back of a model.
if (!(w > 1e-6f))
return false;
uv = Vec2f((m[0] * x + m[1] * y + m[2] * z + m[3]) / w, (m[4] * x + m[5] * y + m[6] * z + m[7]) / w);
return true;
}
float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisplacementLayer &layer,
const Vec3f &position, const Vec3f &normal,
const Vec3f &patch_center, const Vec3f &patch_axis, const Vec2f *lscm_uv)
@@ -957,6 +970,16 @@ float sample_layer_height(const DecodedHeightTexture &texture, const TextureDisp
case TextureProjectionMethod::Spherical:
return sample_at(project_spherical(position, patch_center));
case TextureProjectionMethod::ViewProjected:
if (layer.view_project_projective) {
// Exact projective placement written by the projection-frame overlay. Sampled directly,
// *without* apply_uv_transform(): the matrix already maps the window's border to the uv
// unit square, so the tiling/rotation/offset controls would displace it off the frame
// the user just aligned. A point behind the projector has no uv at all -> no height.
Vec2f uv;
if (!project_uv_projective(layer.view_project_matrix, position, uv))
return 0.f;
return texture.sample(uv, layer.tile_enabled, layer.tile_method);
}
// Flat projection onto the captured projector plane. Single-valued per point, so unlike
// blended triplanar it is one sample, and it is what "project from view" places.
return sample_at(Vec2f(position.dot(layer.view_project_right), position.dot(layer.view_project_up)));
@@ -1163,9 +1186,15 @@ indexed_triangle_set build_texture_displacement(const indexed_triangle_set
int patch_vertex_count = 0;
for (const stl_triangle_vertex_indices &tri : patch.indices)
for (int i = 0; i < 3; ++i) {
average_normal += vertex_normals[tri[i]];
patch_centroid += patch.vertices[tri[i]];
const int vi = tri[i];
patch_centroid += patch.vertices[size_t(vi)];
++patch_vertex_count;
// A brush stroke that split a triangle appends new vertices past the base mesh's own
// (see the get_facets_strict() note above); vertex_normals is sized to the base mesh,
// so those split indices must be skipped here or this reads out of bounds. The main
// displacement loop below guards the same way.
if (vi < int(vertex_normals.size()))
average_normal += vertex_normals[size_t(vi)];
}
average_normal = (average_normal.norm() > 1e-8f) ? Vec3f(average_normal.normalized()) : Vec3f::UnitZ();
patch_centroid = (patch_vertex_count > 0) ? Vec3f(patch_centroid / float(patch_vertex_count)) : Vec3f::Zero();
+33 -4
View File
@@ -7,6 +7,7 @@
#include <vector>
#include <cereal/cereal.hpp>
#include <cereal/types/array.hpp> // view_project_matrix is a std::array<float, 12>
#include <cereal/types/string.hpp>
#include <cereal/types/vector.hpp>
@@ -218,6 +219,24 @@ struct TextureDisplacementLayer
// projects to Vec2f(dot(pos, right), dot(pos, up)) before the usual tiling/rotation/offset.
Vec3f view_project_right = Vec3f::UnitX();
Vec3f view_project_up = Vec3f::UnitY();
// Also ViewProjected, and takes precedence over the two axes above when set: an exact *projective*
// map from a local-space position straight to a texture uv, written by the projection-frame overlay
// (the semi-transparent window dragged over the 3D view -- its border becomes the uv unit square).
//
// Row-major 3x4, applied to the homogeneous point p~ = (x, y, z, 1):
// uv = ( row0.p~ / row2.p~ , row1.p~ / row2.p~ )
// The perspective divide is the whole point. view_project_right/up can only express an *affine*
// projection, which matches an orthographic camera exactly but not a perspective one -- under
// perspective the near end of a part projects larger than the far end, and no pair of axes
// reproduces that. Folding the camera's full projection*view*model product into one matrix does.
// Because a point behind the projector has row2.p~ <= 0 and no meaningful uv, sampling must check
// the sign rather than divide blindly; see project_uv_projective().
//
// Note this map already includes placement, so the usual tiling/rotation/offset transform is NOT
// applied on top of it -- the window's own position and size are the placement.
bool view_project_projective = false;
std::array<float, 12> view_project_matrix{};
// Only used by TextureProjectionMethod::LSCM: hand placement of the unwrap's islands, indexed by
// chart id (see TextureIsland). Shorter than the chart count simply means the missing ones are
// still where the automatic packing put them.
@@ -255,7 +274,7 @@ struct TextureDisplacementLayer
static_cast<int>(tile_method), static_cast<int>(projection_method), lscm_seam_angle_deg, islands,
static_cast<int>(blend_mode), midlevel, island_padding_mm, lscm_seam_edges, view_project_right,
view_project_up, smoothing, edge_smoothing, edge_smoothing_amount, auto_connect_islands, island_groups,
lscm_uv_overrides);
lscm_uv_overrides, view_project_projective, view_project_matrix);
}
template<class Archive> void load(Archive &ar)
{
@@ -266,7 +285,8 @@ struct TextureDisplacementLayer
ar(slot, name, path, path_in_3mf, blob, depth_mm, tiling_scale, rotation_deg, offset, invert, tile_enabled,
tile_method_int, projection_method_int, lscm_seam_angle_deg, islands, blend_mode_int, midlevel,
island_padding_mm, lscm_seam_edges, view_project_right, view_project_up, smoothing, edge_smoothing,
edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides);
edge_smoothing_amount, auto_connect_islands, island_groups, lscm_uv_overrides, view_project_projective,
view_project_matrix);
image_data = blob.empty() ? nullptr : std::make_shared<std::vector<unsigned char>>(blob.begin(), blob.end());
tile_method = static_cast<TextureTileMethod>(tile_method_int);
projection_method = static_cast<TextureProjectionMethod>(projection_method_int);
@@ -283,8 +303,11 @@ struct DecodedHeightTexture
int height = 0;
bool empty() const { return width <= 0 || height <= 0 || pixels.empty(); }
// Bilinearly sampled height in [0, 1] at a normalized uv coordinate. When tile_enabled is
// false, uv is clamped to the texture's edge instead of being wrapped/repeated.
// Bilinearly sampled height in [0, 1] at a normalized uv coordinate. When tile_enabled is false,
// a uv outside [0, 1) samples as 0 -- the texture simply is not there, rather than its border
// row/column being smeared outward forever (which is what clamping the coordinate would do, and
// was a real reported bug). Callers rely on this to get a hard edge: it is how the projection
// frame's border becomes the edge of the displacement.
float sample(const Vec2f &uv, bool tile_enabled = true, TextureTileMethod tile_method = TextureTileMethod::Repeat) const;
};
@@ -307,6 +330,12 @@ Vec2f project_planar(const Vec3f &position, const Vec3f &normal);
// (which only knows how to compute the *analytic* methods from a single vertex + normal).
Vec2f apply_uv_transform(const Vec2f &planar, const TextureDisplacementLayer &layer);
// Applies a row-major 3x4 projective matrix (see TextureDisplacementLayer::view_project_matrix) to a
// local-space point, writing the resulting texture uv. Returns false -- and leaves `uv` untouched --
// when the point lies behind the projector or on its plane (w <= 0), where there is no meaningful uv
// and dividing would produce a mirrored or infinite coordinate. Callers treat that as "no height".
bool project_uv_projective(const std::array<float, 12> &m, const Vec3f &position, Vec2f &uv);
// Sample a layer's height texture at a mesh-local position, honouring the layer's projection
// method, tiling scale, rotation, offset and tiling mode. Returns a height in [0, 1].
//