editing: histogram selection + Blender-style transform

This commit is contained in:
Harry Chen
2026-09-20 23:55:33 -04:00
parent 67547bee39
commit f60271a8c0
55 changed files with 8324 additions and 296 deletions
+24 -4
View File
@@ -138,10 +138,13 @@ src/
│ ├── CrashLog.{h,cpp} the stack trace every tool leaves in <config>/crash.log
│ │ when it faults -- armed for all of them in Main.cpp
│ ├── gui/ Dear ImGui desktop app (`spirula` with no arguments)
│ │ └── edit/ selecting parts of a model and deleting them:
│ │ one document/selection/tool seam over splats,
│ │ sparse points and meshes
│ │ -- docs/notes/gui-editing-plan.md
│ │ └── edit/ selecting parts of a model (by region, by
│ │ attribute, by colour), deleting them, and
│ │ placing the whole model: one document /
│ │ selection / tool seam over splats, sparse
│ │ points and meshes
│ │ -- docs/notes/gui-editing-plan.md,
│ │ docs/notes/scene-transform.md
│ ├── webviewer/ HTTP server + render worker + viewer.html (the ONE
│ │ browser client, embedded into the engine library
│ │ so the CLI and the GUI serve the same bytes)
@@ -631,6 +634,23 @@ no ceremony — do not ask, do not leave a note saying you removed it.
vertex color) is written BEFORE the bake, not after. `generate_mesh()` is
ordered that way on purpose; moving a write past the atlas ships a file whose
colors no longer match its vertices.
- **Rotating a splat model means rotating its SH, and the sign convention is
where that goes wrong.** `core/ShRotation.h` is the closed form
(Ivanic-Ruedenberg), conjugated for the Condon-Shortley phase
`shaders/harmonics.slang` carries; without the conjugation bands 1 and 3 are
wrong by signs a casual render does not show. `sh_rotation_test` holds it to
a sampled fit of that basis and `splat_transform_render` to the engine
itself. Touch the basis and both have to follow. docs/notes/sh-rotation.md.
- **An edited model's placement is applied by the VIEWER until it is saved**
(`EditDoc::placement`), so there are two frames on screen: the elements'
own, and the saved coordinates the grid, the pivot and the alignment helpers
live in. docs/notes/scene-transform.md has the algebra; get a composition
order wrong and the model moves the right amount about the wrong point.
- **The viewport's orthographic view is a pinhole 256x further off with a lens
256x longer** (`ViewportPanel`, `kOrthoPull`), because then every renderer,
primitive and selection test works unchanged. Anything that takes a
RELATIVE depth tolerance has to subtract the pull-back first
(`ViewProjection::ortho_back`).
- **A GUI worker that clears a `busy` flag at the end of its function will
strand it.** Every early `return set_error(...)` skips the line, and the next
request is refused forever. Use a scope guard (`SegmentPanel::start_job`).
+5
View File
@@ -367,6 +367,11 @@ if(SS_BUILD_GUI)
${SS_SRC}/app/gui/Subprocess.cpp)
ss_configure_app(command_argv_test)
add_executable(align_fit_test
${SS_SRC}/app/gui/tests/align_fit_test.cpp
${SS_SRC}/app/gui/edit/AlignFit.cpp)
ss_configure_app(align_fit_test)
add_executable(preset_roundtrip_test
${SS_SRC}/app/gui/tests/preset_roundtrip_test.cpp
${SS_SRC}/app/gui/DatasetPreset.cpp
+76 -4
View File
@@ -16,8 +16,10 @@ exactly as before, and nothing here may cost it a kernel. That turned out to
cost nothing to honour — see "Where the work happens" below, which is why
none of this needed a device kernel on either backend.
Phases 1 and 2 of the order of work below are **built**; what shipped is
recorded at the end of each section.
Phases 1 to 4 of the order of work below are **built** (named groups, part of
phase 3, are not); what shipped is recorded at the end of each section. Phase 4
has its own note: [scene-transform.md](scene-transform.md), with the SH math in
[sh-rotation.md](sh-rotation.md).
## The mistake to avoid
@@ -170,6 +172,38 @@ this composed with a region, and it needs nothing new — paint roughly over the
tree with the brush, then *intersect* with a colour and opacity box. That
composition is the feature; neither half is.
*Built* (`edit/Attributes.{h,cpp}`, `edit/EditAttributes.cpp`), on the host
like the rest: an attribute pass over a million elements is milliseconds, so
the two kernels never had to exist. What shipped:
- The table: opacity; largest, smallest and geometric-mean scale; the same
three as VISIBLE extent (scale x sqrt(2 ln(255 x opacity)), the distance at
which the rasterizer's alpha cut drops the Gaussian -- a huge faint splat is
small here, which is the point); anisotropy as a ratio and as the effective
rank the `erank` regularizer uses; the base colour as R, G, B, luma,
colour-difference U and V, hue and saturation -- display-referred and
UNCLAMPED, so an HDR model keeps its range; a sparse point's distance to the
nearest camera; and position in SAVED coordinates, so that after aligning the
ground "everything below z = 0" is one drag.
- The histogram bins a ROBUST range (0.2th to 99.8th percentile) and the end
bins hold what lies beyond, so dragging the range to the edge of the plot
means "and everything past it". Three floaters a kilometre out do not squash
the plot into one bar. Log axis per attribute, log bar height as a switch,
selected elements drawn over the rest so a range can be steered by what it
catches, live preview in the viewport while dragging, typed ends for a
threshold somebody already knows, and an "outside" switch that doubles as
how a hue range runs through red.
- A range dragged again straight after is an ADJUSTMENT: the step it made is
taken back and replaced, so ten nudges of a threshold are one history entry
and "intersect with this range" re-intersects the ORIGINAL selection rather
than the already-narrowed one.
- Colour: an eyedropper (Shift+click adds samples -- a sky is a gradient, and
one click is one blue), editable swatches, a tolerance measured in OKLab so
equal steps look equally different, and a "match brightness" weight that at
0 compares hue and vividness only, so a surface matches in sun and in shade.
Not built: the 2D density plot, and **named groups**.
**Connected components.** A union-find over whatever says what is joined to
what. That is NOT one rule: a mesh has faces and they are exact, so a mesh
uses them; a Gaussian cloud has extents, so two Gaussians link when they
@@ -346,12 +380,27 @@ src/app/gui/ViewportInput.h the seam ViewportPanel offers a tool
src/data/SparseEdit.{h,cpp} writing an edited reconstruction back out
```
What phases 3 and 4 added:
```
src/app/gui/edit/
Attributes.{h,cpp} the per-element scalar table, the histogram, OKLab matching
EditAttributes.cpp ... the session's half: the brushable plot, the colour sampler
TransformTool.{h,cpp} the modal operator (G/R/S ...) and the handles at the pivot
EditTransform.cpp ... the session's half: frames, steps, the alignment helpers
AlignFit.{h,cpp} RANSAC planes, the click fit, the corner fit, auto align
WorldGrid.{h,cpp} the grid that stands still while the model moves
src/core/Similarity.h Sim3: the one transform a rigid scene can be given
src/core/ShRotation.{h,cpp} closed-form SH band rotation (Ivanic-Ruedenberg)
src/checkpoint/SplatTransform.{h,cpp} what a similarity does to one Gaussian
src/app/gui/ViewportPanel the navigation gizmo, the orthographic view, the
edit transform (all viewports, not only the editor)
```
Still to come, as the later phases arrive:
```
src/app/gui/edit/
Gizmo.{h,cpp}
Attributes.{h,cpp} the per-element scalar table + the brushable histogram
Trajectory.{h,cpp}
```
@@ -408,8 +457,13 @@ widening it.
segmenting a messy model is what the whole feature is for. Camera
selection on a sparse reconstruction came with the layers above.
3. **The histogram panel and named groups.**
*Built*, except named groups: see "Attribute predicates" above.
4. **Transform.** The modal operator, then the gizmo, then SH rotation, then
baking a placement on save.
*Built*, all four, for all three 3D documents, plus what the plan had not
thought of: a navigation gizmo for pointers with no middle button, an
orthographic view, and helpers that FIND the frame -- auto align, click the
ground, click a corner, click the origin. [scene-transform.md](scene-transform.md).
5. **The mask editor.** Path shape, livewire, paint layer, per-frame
corrections.
6. **Trajectories and video export.**
@@ -480,6 +534,24 @@ widening it.
under any other tool the camera gives the letters up and keeps the arrows,
the wheel and the gamepad.
- **A placement puts two frames on screen.** The elements stay where they
were loaded and the viewer applies the placement, which is what makes a drag
free -- and means anything drawn in the model's frame (the renderers' grid)
moves WITH the model. Whatever the model is being placed against has to be
drawn in saved coordinates. [scene-transform.md](scene-transform.md).
- **An alignment that does not take the view along loses the model.** Laying a
wall flat puts it under the camera and off the screen. The view is carried
through alignment steps, and back through their undo.
- **The nearest centre is a floater.** A trained scene is full of faint haze
in front of everything, so a pick that takes the front-most Gaussian under
the cursor takes haze. Walk the ray the way the renderer does and stop where
half the light is gone; fit surfaces through solid Gaussians only.
- **A local fit needs no tolerance, or it needs the right one.** RANSAC with a
thickness sized for a tabletop finds an arbitrary thin slice of a lawn. The
click fit is a least-quantile fit through the clicked point instead.
- **A second save must not read the first save's output.** Row indices and
poses both refer to the files as the session found them (`SparseBaseline`).
## Testing it
Two levels, and the cheaper one should carry most of the coverage.
+221
View File
@@ -0,0 +1,221 @@
# Placing a model: frames, conventions and what gets saved
Phase 4 of [gui-editing-plan.md](gui-editing-plan.md): moving, turning and
resizing a whole model in the editor, and the helpers that find the frame a
scene wants (its ground, a corner, one-click auto align). The scene is taken to
be RIGID: one similarity -- a rotation, a uniform scale, a translation -- for
everything in the document, cameras included. `src/core/Similarity.h` is that
object (`Sim3`, double throughout: a geo-referenced model sits millions of
units from its origin).
## The decision that shapes everything: the viewer applies it
A placement is NOT written into the elements while it is being edited. The
document holds one `Sim3` (`EditDoc::placement()`), the viewport applies it by
moving the CAMERA the other way (`ViewportPanel::set_edit_transform`, the same
trick `set_model_transform` already played for the comparison view), and only
a save bakes it into the file.
What that buys:
- A drag costs nothing. No re-upload of a million Gaussians per frame, no
second copy of the model, and the view-dependent colour is right for free --
rendering a model from a moved camera IS the rotated model, SH and all.
- Undo is two `Sim3`s (`make_placement_op` stores both ends rather than the
step between them, so walking the history never accumulates rounding).
- Every selection tool keeps working unchanged: the elements never left the
frame they were loaded in, and `view_camera()` already reports the camera in
that frame.
What it costs is that there are now two frames on screen, which is the rest of
this note.
## The frames
| frame | what is in it | who defines it |
|---|---|---|
| file | the elements as the file stores them | the file |
| positions() | the same, normalized for navigation | `EditDoc::view_frame()` = N |
| shared | what the camera navigates | the panel: `base` x `placement` x positions |
| saved | the file's coordinates AFTER the placement | what a save writes |
With `E` the placement (a similarity of the positions() frame) and `B` the
panel's base transform (owner placement and levelling):
shared = B E p a model point on screen
saved = N^-1 E p where that point will be in the file
shared = (B N) saved so B N maps saved coordinates to the screen
`B N` does not contain `E`. That is the whole point: the grid, the axes, the
pivot called "origin", the numbers in the Placement fields and everything the
alignment helpers compute live in SAVED coordinates, which stand still on
screen while the model moves through them.
Two kinds of step, and how each becomes a new placement:
a step D made in the shared frame (the modal operator, a handle drag):
E' = B^-1 D B E
a step D made in saved coordinates (every alignment helper, the fields):
E' = N D N^-1 E
and at save time the file gets `N^-1 E N` (`EditDoc::file_placement()`).
## The grid has to stand still
The renderers draw their grid in the MODEL's frame -- the engine ray-traces it
as capsules inside the scene, the GL preview builds it from `_t2n`. That is
right until the model is what is moving, and then it is exactly wrong: the
grid would ride along with the thing being aligned to it.
So once a document has been moved (or an operator is running) the session
takes the grid over (`ViewportInteractor::draws_world_grid`): the renderers
are told not to draw theirs, and `edit/WorldGrid.cpp` draws one as an overlay
in saved coordinates through `B N`. It is not depth-tested against the model.
Giving the engine's grid a transform was the alternative; it is a kernel-level
change on both backends plus a BVH rebuild per dragged frame, for a line
overlay.
## The view follows an alignment
An alignment defines the WORLD under the model; it is not the model being
carried somewhere. Done naively, "click the ground" on a wall you are facing
lays the wall flat five units below the camera and the model vanishes from the
screen -- which was the first thing the first test did. So the alignment steps
(`place_saved(..., carry=true)`) take the view along: the camera's position
and pivot go through the same step and the view is then stood upright again
(`ViewportPanel::carry_view`). What the user sees is the model staying put and
the grid arriving under it. Undo and redo across such a step carry the view
back the same way (`EditOp::carries_view`, `EditSession::follow_history`).
Manual moves, turns and quarter turns do not: there the model is what is meant
to be seen moving.
## The modal operator
`edit/TransformTool.{h,cpp}`. The grammar is Blender's, because that is what
the people asking for this already have in their hands:
G / R / S move / rotate / scale, following the pointer
X / Y / Z constrain to that axis; again: the model's own axis; again: free
Shift+X / Y / Z (move) constrain to the plane across that axis
digits . - type the value: file units, degrees, or a factor
Shift precision: the pointer counts a tenth from here on
Ctrl snap: one grid cell, 5 degrees, 0.1 (a tenth of each with Shift)
Enter / click confirm Esc / right-click cancel
The on-screen handles are the same operator started with its constraint already
chosen and confirmed by letting go -- one code path, two ways in. Scale is
uniform only, by construction.
Three details that are easy to get wrong:
- **Axis-constrained move is measured on screen.** The pointer's travel is
projected onto the axis AS DRAWN (pixels per unit along it at the pivot).
Intersecting the pointer ray with the axis line is the textbook answer and it
blows up as the axis turns toward the eye.
- **Precision mode accumulates.** Shift slows the pointer tenfold from where
it is, so the operator follows a virtual pointer, not the real one; a
rotation likewise accumulates its angle so a drag can pass 180 degrees.
- **`S` is also "fly backwards".** Under the Navigate tool WASDQE belong to the
camera, so there `S` does not start a scale (`G` and `R` still work). The
Transform tool takes the letter keys, and pressing `G`/`R` enters it.
## Orthographic without an orthographic renderer
Clicking an axis on the navigation gizmo looks along it in an orthographic
view. Neither backend has an orthographic projection, and adding one is a
change to the projection of three primitives on two backends with parity tests.
Instead `ViewportPanel` renders orthographic views as a pinhole pulled back 256
times as far with a lens 256 times as long (`kOrthoPull`). At that ratio a box
as deep as the view distance changes size by 0.4% front to back, float still
resolves 3e-5 of the view distance, and -- the real win -- every renderer,
every primitive and every selection test works unchanged, because to all of
them it is a pinhole.
Two places have to know: the GL preview's linear depth range moves out with the
camera (`PreviewRenderer::render(..., ortho_back)`), and the "visible only"
occlusion test takes its relative slack from the navigated distance rather than
the pulled-back one (`ViewProjection::ortho_back`).
## Saving: what each format needs
**Splats** (`checkpoint/SplatTransform.h`): `mean -> s R mean + t`,
`quat -> q_R * quat`, `log scale -> + ln s`, SH bands rotated
([sh-rotation.md](sh-rotation.md)); opacity and DC untouched.
`write_splat_ply` applies it row by row as it writes, so baking costs no second
copy of the model.
**Meshes**: vertices through the similarity, normals through the rotation. A
linked save moves the sibling files too -- faces are matched in the coordinates
the file was written in, then moved.
**COLMAP** (`data/SparseEdit.cpp`, `move_w2c`). A pose is world-to-camera,
`x_cam = R x + t`, and a camera is rigid: it cannot carry the scene's scale.
With the world moved by `x' = s Q x + u`:
R' = R Q^T t' = s t - R' u
The camera frame grows by `s` and nothing about any image changes. Points are
moved directly. `images.bin`/`.txt`, `points3D.bin`/`.txt` are handled, and
`frames.bin` (COLMAP 3.12+, where `rig_from_world` is what COLMAP itself reads)
is moved too when its layout accounts for every byte of the file -- a file this
cannot read exactly is one it must not rewrite. A multi-sensor rig's internal
baselines (`rigs.bin`) are NOT rescaled; with `s = 1` there is nothing to do.
**Nerfstudio**. The parser hands out poses with `applied_transform` UNDONE
(`p_raw = A^-1 (p_json - b)`), so a placement `T` made in that raw frame is the
conjugate `A T A^-1` in the frame the file is written in. The frames'
`transform_matrix` (camera-to-world, OpenGL axes) and the point PLY are both
moved by that conjugate; the camera axes are only ROTATED, because a
transform_matrix whose columns stopped being unit would be a lens. The file's
`applied_transform` is left exactly as it was: what the parser undoes is then
still the axis convention and nothing else, and both this trainer and
nerfstudio see the same scene, moved.
**Metashape** is not ours to rewrite, so as before the edit lands beside it as
a Nerfstudio dataset, moved the same way.
`src/data/tests/sparse_transform_test.cpp` writes each format, moves it,
re-parses it and requires the parser's cameras and points to be the originals
under the same similarity AND every point to land on the pixel it did before
(measured: 5e-5 px).
### The session baseline
A row filter indexes the rows of the file it reads, and a placement starts from
the poses in it. A SECOND save of one session therefore cannot read the file
the first save wrote -- it would filter already-filtered rows by the original
indices and move already-moved poses. `SparseBaseline` holds the files as the
session first found them and every save starts from it. (The double filter was
a latent bug in phase 1; the transform made it impossible to miss.)
## Finding the frame a scene wants
`edit/AlignFit.{h,cpp}`: pure geometry over a point array, tested without a
window (`app/gui/tests/align_fit_test.cpp`, a tipped-over room with a box in
it, noise and floaters).
- **Auto align.** Up to six planes by sequential RANSAC, each refit by least
squares. The ground is the best-supported plane with the scene ON it (which
is what tells a floor from a ceiling and, more often, from the biggest wall).
With cameras, their mean up axis is a trusted prior and the ground must face
it; WITHOUT cameras the file's +Z is only a soft hint and up is whichever side
of the plane the scene is on -- a splat file that arrived lying on its side
(the Mip-NeRF 360 bicycle is 104 degrees off) has to work. Then the walls:
everything upright votes for a heading with its angle multiplied by four, so
the four faces of a room agree; from normals where the document has them (a
flat Gaussian's thin axis, a mesh vertex normal), from the RANSAC planes
where it has not. Then the median of the footprint goes to the origin.
- **Click the ground.** The plane through the click with the smallest
35th-percentile residual (least quantile of squares), then grown outward
while the wider patch keeps agreeing. No tolerance to choose -- a lawn is
centimetres thick and a tabletop is not, and a click cannot say which -- and
a wall beside the click can hold most of the neighbourhood without winning,
because it does not pass through the click. Up is the side the eye is on.
- **Click a corner.** Up to three mutually square planes near the click, made
exactly orthogonal, each sent to the axis it is already nearest so the model
turns as little as it can.
- **Haze is not a surface.** For splats the fits skip faint (opacity < 0.3) and
oversized Gaussians (`EditDoc::solidity`), and the pick walks the pixel's ray
front to back until half the light is gone -- the renderer's own answer to
"what is here" -- instead of taking the nearest centre, which in a trained
scene is a floater.
+114
View File
@@ -0,0 +1,114 @@
# Rotating spherical harmonics with a model
Turning a trained model is three lines for its geometry and one real problem
for its colour. The view-dependent part of a Gaussian's colour is a set of
real spherical-harmonic coefficients, evaluated in the direction from the
camera to the Gaussian. Rotate the geometry and leave those coefficients
alone and the highlights stay where they were in the old world while the
object turns under them. This note derives what has to happen to them instead,
and records how that is checked. The code is `src/core/ShRotation.{h,cpp}`;
what uses it is `src/checkpoint/SplatTransform.{h,cpp}`.
## What has to hold
Write a Gaussian's colour as
colour(d) = sum over l, m of c_lm * Y_lm(d)
with `d` the unit view direction and `Y_lm` the real basis
`shaders/harmonics.slang` evaluates (`sh_coeffs_to_color`), bands `l = 0..4`.
Rotate the whole scene by `R` -- model and camera together -- and nothing about
the image may change. The direction that was `d` is now `R d`, so the rotated
model's coefficients `c'` have to satisfy
colour'(R d) = colour(d) for every d.
## The band matrices
Rotations do not mix bands. For each `l` there is an orthogonal
`(2l+1) x (2l+1)` matrix `M_l(R)`, a representation of the rotation group,
with
Y_l(R d) = M_l(R) Y_l(d)
where `Y_l(d)` is the column of the `2l+1` basis values of band `l`. Substitute:
colour'(R d) = c'_l . Y_l(R d) = c'_l . M_l(R) Y_l(d) = (M_l(R)^T c'_l) . Y_l(d)
and this equals `c_l . Y_l(d)` for every `d` exactly when `M_l(R)^T c'_l = c_l`.
`M_l` is orthogonal, so
c'_l = M_l(R) c_l.
Band 0 is a constant and does not move, which is why the DC colour and the
opacity are untouched by a placement. A translation and a uniform scale do not
change any direction, so only `R` matters.
## Closed form: Ivanic-Ruedenberg
`M_l` can be had by sampling: evaluate the basis at many `d` and at `R d` and
solve for the matrix that maps one to the other. That is what several
implementations in the wild do, and it is what the test below uses as its
reference -- but it is a least-squares solve per rotation, and its accuracy is
the sampling's.
The closed form is the recursion of Ivanic and Ruedenberg ("Rotation Matrices
for Real Spherical Harmonics. Direct Determination by Recursion", J. Phys. Chem.
100:6342, 1996, with the 1998 erratum): `M_l` from `M_1` and `M_(l-1)`, a few
hundred multiplies for all of bands 1 to 4. A placement is one rotation for a
whole model, so the matrices are built ONCE and every Gaussian is then a
matrix-vector product per band per channel: 3 x (9 + 25 + 49 + 81) multiplies
at degree 4.
## The sign convention, which is where this goes wrong
The recursion is stated for the real harmonics WITHOUT the Condon-Shortley
phase. In that basis band 1 is `(y, z, x)`, so `M_1` is simply `R` with its
rows and columns permuted the same way, and the recursion takes it from there.
`harmonics.slang` carries the phase: its band 1 is `(-y, z, -x)`, and in
general its `Y_lm` is `(-1)^m` times the phase-free one. With `S_l` the
diagonal matrix of those signs, `Y^engine = S_l Y^plain`, so
M_l^engine = S_l M_l^plain S_l
-- entry `(m, n)` changes sign exactly when `m + n` is odd. `ShRotation` runs
the recursion in the phase-free basis and applies that conjugation at the end.
Skipping the conjugation is the classic mistake, and it is a quiet one: bands 1
and 3 come out wrong by signs that a casual look at a render does not catch. It
was caught here before any C++ was written, by prototyping both variants
against the sampled fit: the phase-free matrices were off by order 1 in every
band, the conjugated ones agreed to 1e-15.
## How it is held to that
`src/core/tests/sh_rotation_test.cpp` transcribes the 25 basis functions from
`harmonics.slang`, constant for constant, and checks, over random rotations
plus the quarter and half turns about each axis:
- every band matrix equals the sampled least-squares fit of that basis
(measured: 2.8e-15);
- every band matrix is orthogonal;
- `M(R1 R2) = M(R1) M(R2)`;
- for degrees 0 to 4, random coefficients rotated by `apply()` give
`colour'(R d) = colour(d)` in float;
- coefficient rows past the last complete band are left alone.
That test shares no code with the engine, so it cannot say the transcription
is right. `src/backend/tests/engine/splat_transform_render.cpp` closes the
loop on the real thing: render a random scene with strong view dependence,
bake a similarity into the splats with `transform_splats`, move the camera by
the same similarity, render again. The two images have to match -- measured
mean |difference| 3e-7 for 3dgs, mip and 3dgut, pinhole and fisheye, SH degree
3 and 4 -- and a control that moves everything EXCEPT the SH has to differ
visibly (it does, by 0.07 to 0.13), so the test is known to see the thing it is
for.
One thing that test found that is not about SH: the 3dgut rasterizer builds its
rotation from the stored quaternion as is, without normalizing, so it is only
rotation-equivariant for unit quaternions. The optimizer leaves them unit after
every step (`FusedGeometryOptim.cu`), so trained models are fine, and
`SplatTransform` keeps whatever length the file had rather than renormalizing
-- a second edit nobody asked for.
+29 -6
View File
@@ -116,7 +116,7 @@ void CompareView::set_shown(const std::string& path, bool on,
void CompareView::remove(int index) {
if (index < 0 || index >= count()) return;
if (_edit_index == index) end_edit();
if (_edit_index == index) end_edit(false);
else if (_edit_index > index) _edit_index--;
Model& m = *_models[index];
m.panel.detach();
@@ -138,7 +138,7 @@ void CompareView::move(int index, int dir) {
}
void CompareView::close() {
end_edit();
end_edit(/*reload_panes=*/false);
// No destroy_gl here: close() also runs from the destructor, by which
// point the GL context may be gone. GuiApp::shutdown calls destroy_gl()
// while it is still current.
@@ -327,15 +327,35 @@ void CompareView::confirm_discard_edits(std::function<void()> then) {
_ask_discard = true;
}
void CompareView::end_edit() {
void CompareView::reload(int index) {
if (index < 0 || index >= count()) return;
Model& m = *_models[(size_t)index];
m.panel.detach();
m.src.close();
m.src.open(m.path, m.slot, &_engine_mutex);
m.attached = false;
if (index == 0) _overlay_key.clear();
}
void CompareView::end_edit(bool reload_panes) {
_edit_when_ready = false;
// Whatever the edit did to the other panes goes back with it.
if (_edit_index >= 0) show_sibling_meshes(_edit_index, FaceCut{});
if (_edit_worker.joinable()) _edit_worker.join();
_edit_loading = false;
_edit_pending.reset();
// The pane goes back to what it LOADED, and after a save over that file
// what it loaded is no longer what is on disk.
const int index = _edit_index;
const bool stale = _edit.active() && _edit.saved_over_source();
const bool linked = stale && _edit.doc()->linked_count() > 0;
_edit.close();
_edit_index = -1;
if (!stale || !reload_panes) return;
for (int i = 0; i < count(); i++)
if (i == index || (linked && _models[(size_t)i]->attached &&
_models[(size_t)i]->src.kind() == SplatViewer::Kind::Mesh))
reload(i);
}
void CompareView::begin_edit(int index) {
@@ -395,12 +415,15 @@ void CompareView::begin_edit(int index) {
std::mutex* mu = m.src.engine_mutex();
float t2v[12];
m.src.to_view_frame(t2v);
const bool linear = m.src.linear_color();
_edit_loading = true;
_edit_worker = std::thread([this, file, slot, mu, t2v] {
_edit_worker = std::thread([this, file, slot, mu, t2v, linear] {
try {
spirula::SplatCloud c = spirula::read_splat_ply(file);
_edit_pending = std::make_unique<SplatDoc>(
std::move(c), file, t2v, slot, mu);
auto doc = std::make_unique<SplatDoc>(std::move(c), file,
t2v, slot, mu);
doc->set_linear_colour(linear);
_edit_pending = std::move(doc);
} catch (const std::exception& e) {
_edit_error = e.what();
}
+5 -1
View File
@@ -73,7 +73,8 @@ public:
// ... as soon as the first model's loader finishes, which is how a screen
// hands a model it has only just asked for straight to the editor.
void edit_first_when_ready() { _edit_when_ready = true; }
void end_edit();
// `reload` false when the panes are going away with the edit.
void end_edit(bool reload = true);
int editing() const { return _edit_index; }
bool edit_busy() const { return _edit_loading.load(); }
EditSession& edit() { return _edit; }
@@ -113,6 +114,9 @@ private:
};
void take_engine();
// Read a pane's model from disk again, in place: what a save over the
// file it was loaded from leaves it needing.
void reload(int index);
void remove(int index);
void move(int index, int dir);
int claim_slot();
+10 -3
View File
@@ -940,13 +940,20 @@ unsigned PreviewRenderer::render(int W, int H, const float view[16],
PreviewProjection proj, float sx, float sy,
float scene_radius, float view_dist,
const float view_target[3], bool show_cams,
float frustum_scale, bool show_grid) {
float frustum_scale, bool show_grid,
float ortho_back) {
if (!_built || !_gl_ok || W < 1 || H < 1) return 0;
if (!ensure_fbo(W, H)) return 0;
if (show_grid) ensure_grid(scene_radius, view_dist, view_target);
float zn = std::max(1e-5f, 0.002f * scene_radius);
float zf = std::max(10.0f * zn, 500.0f * scene_radius);
// Depth is LINEAR over this range, so a near plane costs no precision;
// what it must do is hold a camera that a placement moved a long way off.
float zn = std::max(1e-7f, 0.002f * std::min(scene_radius, view_dist));
float zf = std::max({10.0f * zn, 500.0f * scene_radius, 20.0f * view_dist});
if (ortho_back > 0.0f) {
zn = std::max(zn, ortho_back - zf);
zf = ortho_back + zf;
}
glx::BindFramebuffer(GL_FRAMEBUFFER, (GLuint)_fbo);
glViewport(0, 0, W, H);
+4 -1
View File
@@ -76,7 +76,10 @@ public:
PreviewProjection proj, float sx, float sy,
float scene_radius, float view_dist,
const float view_target[3], bool show_cams,
float frustum_scale, bool show_grid);
float frustum_scale, bool show_grid,
// How far an orthographic view's camera was pulled back
// along its axis (ViewportPanel::ortho_pullback), 0 if not.
float ortho_back = 0.0f);
// Base frustum size (camhost::frustum_display_size, normalized frame).
float base_camera_size() const { return _base_cam_size; }
+3
View File
@@ -277,6 +277,9 @@ inline bool MenuItem(const Msg& m, std::initializer_list<Arg> a) {
inline bool MenuItemRaw(const char* s, bool selected = false) {
return ImGui::MenuItem(s, nullptr, selected);
}
inline bool BeginTabItem(const Msg& m, ImGuiTabItemFlags flags = 0) {
return ImGui::BeginTabItem(detail::label(m), nullptr, flags);
}
inline bool CollapsingHeader(const Msg& m, ImGuiTreeNodeFlags flags = 0) {
return ImGui::CollapsingHeader(detail::label(m), flags);
}
+11
View File
@@ -26,6 +26,10 @@ struct ViewportInput {
struct ViewportOverlay {
ImDrawList* dl = nullptr;
float x = 0, y = 0, w = 0, h = 0;
// The grid switch and its cell, in model units, for an interactor that
// draws the grid itself (draws_world_grid).
bool grid = false;
float grid_cell = 1.0f;
};
// An interaction that owns the viewport's LEFT button while it is installed;
@@ -40,6 +44,13 @@ struct ViewportInteractor {
// the same question: the key that switches back to navigation is one of
// them, and it is still down on the frame the switch happens.
virtual bool blocks_fly_keys() const { return owns_left_button(); }
// A modal operation cancels on the right button, which the panel would
// otherwise start a pan with.
virtual bool owns_right_button() const { return false; }
// The renderers draw their grid in the MODEL's frame, which is the wrong
// one while the model is being placed against it. True hands the grid to
// draw_viewport_overlay, fixed in the frame the model moves through.
virtual bool draws_world_grid() const { return false; }
// True when the tool took this frame's left button.
virtual bool on_viewport_input(const ViewportInput& in) = 0;
virtual void draw_viewport_overlay(const ViewportOverlay& v) = 0;
+529 -45
View File
@@ -55,6 +55,46 @@ void fov_to_intrinsics(float fov_deg, int w, int h, const char* model,
fy = fx;
}
// Orthographic as a pinhole this many times further off with a lens this
// many times longer. At 256 a box as deep as the view distance changes size
// by 0.4% front to back, and float still resolves 3e-5 of that distance.
constexpr float kOrthoPull = 256.0f;
// An axis snap turns the view over this long rather than jumping: the eye
// keeps track of which way up the model is.
constexpr double kSnapSeconds = 0.18;
void quat_slerp(const float a[4], const float b[4], float t, float out[4]) {
float d = a[0]*b[0] + a[1]*b[1] + a[2]*b[2] + a[3]*b[3];
float sgn = d < 0 ? -1.0f : 1.0f;
d = std::fabs(d);
float ka = 1.0f - t, kb = t;
if (d < 0.9995f) {
const float th = std::acos(d), sn = std::sin(th);
ka = std::sin((1.0f - t) * th) / sn;
kb = std::sin(t * th) / sn;
}
float n = 0.0f;
for (int i = 0; i < 4; i++) {
out[i] = ka * a[i] + kb * sgn * b[i];
n += out[i] * out[i];
}
n = std::sqrt(std::max(n, 1e-20f));
for (int i = 0; i < 4; i++) out[i] /= n;
}
// a after b, both row-major 3x4.
void compose_3x4(const float a[12], const float b[12], float out[12]) {
float o[12];
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 4; c++) {
float v = c == 3 ? a[r*4+3] : 0.0f;
for (int k = 0; k < 3; k++) v += a[r*4+k] * b[k*4+c];
o[r*4+c] = v;
}
}
std::memcpy(out, o, sizeof o);
}
} // namespace
// ---------------------------------------------------------------------------
@@ -178,6 +218,117 @@ void ViewportPanel::compute_intrinsics(int W, int H, float& fx, float& fy) const
} else {
fov_to_intrinsics(_fov_deg[_cam_model], W, H, model, fx, fy);
}
if (ortho_back() > 0.0f) {
fx *= kOrthoPull;
fy *= kOrthoPull;
}
}
// The distance the render camera stands behind the navigated one. The depth
// that keeps its size is the pivot's, which is what an orbit turns about.
float ViewportPanel::ortho_back() const {
if (!_ortho || _cam_model != 0) return 0.0f;
float f[3];
_cam.axis_forward(f);
float d = (_cam.target[0] - _cam.pos[0]) * f[0] +
(_cam.target[1] - _cam.pos[1]) * f[1] +
(_cam.target[2] - _cam.pos[2]) * f[2];
if (!(d > 1e-6f)) d = std::max(nav_dist(), 1e-6f);
return d * (kOrthoPull - 1.0f);
}
float ViewportPanel::ortho_pullback(bool shared) const {
const float b = ortho_back();
return shared ? b : b / _m2s_scale;
}
void ViewportPanel::render_c2w(float out[12]) const {
_cam.c2w(out);
const float back = ortho_back();
if (back <= 0.0f) return;
float f[3];
_cam.axis_forward(f);
out[3] -= f[0] * back;
out[7] -= f[1] * back;
out[11] -= f[2] * back;
}
void ViewportPanel::set_ortho(bool on) {
if (on && _cam_model != 0) {
_cam_model = 0;
_fov_deg[0] = std::clamp(_fov_deg[0], fov_min(), fov_max());
}
if (_ortho == on && !_ortho_auto) return;
_ortho = on;
_ortho_auto = false;
_dirty = true;
}
void ViewportPanel::snap_view(int axis, bool negative) {
axis = std::clamp(axis, 0, 2);
const float dist = std::max(nav_dist(), 1e-6f);
float dir[3] = {0, 0, 0};
dir[axis] = negative ? -1.0f : 1.0f;
const float eye[3] = {_cam.target[0] + dir[0] * dist,
_cam.target[1] + dir[1] * dist,
_cam.target[2] + dir[2] * dist};
// Looking straight down +Z has no up left in +Z; +Y is what a plan view
// puts at the top of the page.
const float up_z[3] = {0, 0, 1}, up_y[3] = {0, 1, 0};
NavCamera to = _cam;
const float tgt[3] = {_cam.target[0], _cam.target[1], _cam.target[2]};
to.look_at(eye, tgt, axis == 2 ? up_y : up_z);
std::memcpy(_anim_from, _cam.rot, sizeof _anim_from);
std::memcpy(_anim_to, to.rot, sizeof _anim_to);
_anim = true;
_anim_t0 = ImGui::GetTime();
if (_cam_model != 0) _cam_model = 0;
_ortho = true;
_ortho_auto = true;
_dirty = true;
}
void ViewportPanel::carry_view(const float d[12]) {
auto carry = [&](NavCamera& cam) {
float fwd[3];
cam.axis_forward(fwd);
float pos[3], tgt[3];
for (int r = 0; r < 3; r++) {
pos[r] = d[r*4]*cam.pos[0] + d[r*4+1]*cam.pos[1] + d[r*4+2]*cam.pos[2] + d[r*4+3];
tgt[r] = d[r*4]*cam.target[0] + d[r*4+1]*cam.target[1] +
d[r*4+2]*cam.target[2] + d[r*4+3];
}
// The pivot can sit off the optical axis after a pan; what is looked
// AT is the point straight ahead at the pivot's distance.
const float scale = std::sqrt(d[0]*d[0] + d[4]*d[4] + d[8]*d[8]);
float dist = 0.0f;
for (int k = 0; k < 3; k++) dist += (cam.target[k] - cam.pos[k]) * fwd[k];
dist = std::max(std::fabs(dist), 1e-6f) * scale;
float f2[3], ahead[3];
for (int r = 0; r < 3; r++)
f2[r] = (d[r*4]*fwd[0] + d[r*4+1]*fwd[1] + d[r*4+2]*fwd[2]) / scale;
for (int k = 0; k < 3; k++) ahead[k] = pos[k] + f2[k] * dist;
const float up_z[3] = {0, 0, 1}, up_y[3] = {0, 1, 0};
cam.look_at(pos, ahead, std::fabs(f2[2]) > 0.999f ? up_y : up_z);
for (int k = 0; k < 3; k++) cam.target[k] = tgt[k];
};
carry(_cam);
carry(_home);
_anim = false;
_dirty = true;
}
void ViewportPanel::animate_view(double now) {
if (!_anim) return;
float t = (float)std::clamp((now - _anim_t0) / kSnapSeconds, 0.0, 1.0);
t = t * t * (3.0f - 2.0f * t);
const float dist = std::max(nav_dist(), 1e-6f);
quat_slerp(_anim_from, _anim_to, t, _cam.rot);
float back[3];
_cam.axis_forward(back);
for (int i = 0; i < 3; i++) _cam.pos[i] = _cam.target[i] - back[i] * dist;
_dirty = true;
if (t >= 1.0f) _anim = false;
}
float ViewportPanel::nav_dist() const {
@@ -196,12 +347,11 @@ void ViewportPanel::set_model_transform(const float a[12]) {
_dirty = true;
}
// owner placement composed with the levelling correction, which is R_align
// transposed when the parsers' up guess is switched off and nothing otherwise.
// owner placement, then the levelling correction (R_align transposed when
// the parsers' up guess is switched off), then whatever placement is being
// edited -- innermost, so it happens in the model's own frame.
void ViewportPanel::rebuild_m2s() {
const float* o = _m2s_owner;
_m2s_scale = std::sqrt(o[0]*o[0] + o[4]*o[4] + o[8]*o[8]);
if (!(_m2s_scale > 1e-20f)) _m2s_scale = 1.0f;
const bool corr = !_level_cameras && !_align_identity;
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++) {
@@ -210,14 +360,38 @@ void ViewportPanel::rebuild_m2s() {
for (int k = 0; k < 3; k++) v += o[r*4+k] * _align[c*3+k];
else
v = o[r*4+c];
_m2s[r*4+c] = v;
_m2s_base[r*4+c] = v;
}
_m2s[r*4+3] = o[r*4+3];
_m2s_base[r*4+3] = o[r*4+3];
}
compose_3x4(_m2s_base, _m2s_edit, _m2s);
_m2s_scale = std::sqrt(_m2s[0]*_m2s[0] + _m2s[4]*_m2s[4] + _m2s[8]*_m2s[8]);
if (!(_m2s_scale > 1e-20f)) _m2s_scale = 1.0f;
static const float kI[12] = {1,0,0,0, 0,1,0,0, 0,0,1,0};
_m2s_identity = std::memcmp(_m2s, kI, sizeof kI) == 0;
}
void ViewportPanel::set_edit_transform(const float a[12]) {
if (std::memcmp(_m2s_edit, a, sizeof _m2s_edit) == 0) return;
std::memcpy(_m2s_edit, a, sizeof _m2s_edit);
rebuild_m2s();
// A model being dragged is a camera being moved as far as the render's
// cost goes, so it gets the same half-resolution frames.
_last_move = ImGui::GetTime();
_dirty = true;
}
void ViewportPanel::base_transform(float out[12]) const {
std::memcpy(out, _m2s_base, sizeof _m2s_base);
}
void ViewportPanel::set_level_cameras(bool on) {
if (_level_cameras == on) return;
_level_cameras = on;
rebuild_m2s();
_dirty = true;
}
void ViewportPanel::adopt_gauge(const ParsedDataset& ds, bool first) {
for (int k = 0; k < 9; k++) _align[k] = ds.normalized_rotation[k];
_align_identity = true;
@@ -244,6 +418,19 @@ float ViewportPanel::grid_cell() const {
return std::pow(10.0f, std::floor(std::log10(std::max(d, 1e-6f) * 0.5f)));
}
// The same rule over the BASE frame: a grid the model is placed against must
// not rescale because the model did.
float ViewportPanel::world_grid_cell() const {
const float bs = std::sqrt(_m2s_base[0]*_m2s_base[0] + _m2s_base[4]*_m2s_base[4] +
_m2s_base[8]*_m2s_base[8]);
const float d = nav_dist() / std::max(bs, 1e-20f) * _scene_scale;
return std::pow(10.0f, std::floor(std::log10(std::max(d, 1e-6f) * 0.5f)));
}
bool ViewportPanel::external_grid() const {
return _interactor && _interactor->draws_world_grid();
}
// Shared -> model: R^T (x - t) / s, with the 3x3 written as s*R.
void ViewportPanel::model_point(const float shared[3], float out[3]) const {
if (_m2s_identity) {
@@ -267,7 +454,7 @@ void ViewportPanel::shared_point(const float model[3], float out[3]) const {
}
void ViewportPanel::model_c2w(float out[12]) const {
_cam.c2w(out);
render_c2w(out);
if (_m2s_identity) return;
const float s = _m2s_scale;
float m[12];
@@ -299,7 +486,7 @@ void ViewportPanel::build_request(ViewRequest& q, int W, int H) const {
q.model = camera_model_name();
q.key = _buffer_keys.empty() ? "rgb" : _buffer_keys[_buffer_idx];
q.show_cams = _show_cams;
q.show_grid = _show_grid;
q.show_grid = _show_grid && !external_grid();
q.grid_dist = nav_dist() / _m2s_scale;
model_point(_cam.target, q.grid_target);
q.cam_size_scale = _frustum_scale;
@@ -309,7 +496,7 @@ void ViewportPanel::build_request(ViewRequest& q, int W, int H) const {
// the point count and the grid legend stack without measuring the font twice.
void ViewportPanel::draw_grid_overlay(float x, float y, int line) const {
if (!_show_grid) return;
const float c = grid_cell();
const float c = external_grid() ? world_grid_cell() : grid_cell();
char buf[32];
if (_gauge_metric) {
// Symbols, not words: km/m/cm/mm read the same in every language.
@@ -348,11 +535,7 @@ void ViewportPanel::view_matrix(float out[16]) const {
// The same pose in the CV convention a selection projects through: the c2w
// columns are the GL view axes, and CV is (x, -y, -z) of them.
void ViewportPanel::view_camera(int W, int H, float w2c[12], float& fx,
float& fy, int& camera_model,
float eye[3]) const {
float m[12];
model_c2w(m);
static void cv_w2c(const float m[12], float w2c[12], float eye[3]) {
const float sign[3] = {1.0f, -1.0f, -1.0f};
for (int r = 0; r < 3; r++) {
float t = 0.0f;
@@ -366,6 +549,24 @@ void ViewportPanel::view_camera(int W, int H, float w2c[12], float& fx,
eye[0] = m[3];
eye[1] = m[7];
eye[2] = m[11];
}
void ViewportPanel::view_camera(int W, int H, float w2c[12], float& fx,
float& fy, int& camera_model,
float eye[3]) const {
float m[12];
model_c2w(m);
cv_w2c(m, w2c, eye);
compute_intrinsics(W, H, fx, fy);
camera_model = _cam_model;
}
void ViewportPanel::nav_camera(int W, int H, float w2c[12], float& fx,
float& fy, int& camera_model,
float eye[3]) const {
float m[12];
render_c2w(m);
cv_w2c(m, w2c, eye);
compute_intrinsics(W, H, fx, fy);
camera_model = _cam_model;
}
@@ -559,6 +760,11 @@ void ViewportPanel::handle_input(float /*item_h*/) {
_img_w = rsz.x;
_img_h = rsz.y;
// The gizmo sits on top of the image, so it answers first: a click on it
// is neither a tool's nor the start of a drag on what is under it.
if (gizmo_input(hovered)) hovered = false;
animate_view(ImGui::GetTime());
// A tool owns the left button for its whole lifetime, so that "does this
// drag orbit or lasso?" is answered once rather than per feature.
bool tool_owns_left = false;
@@ -569,9 +775,10 @@ void ViewportPanel::handle_input(float /*item_h*/) {
in.y = io.MousePos.y - rmin.y;
in.W = (int)rsz.x;
in.H = (int)rsz.y;
in.down = ImGui::IsMouseDown(ImGuiMouseButton_Left);
in.clicked = ImGui::IsMouseClicked(ImGuiMouseButton_Left);
in.released = ImGui::IsMouseReleased(ImGuiMouseButton_Left);
in.down = !_giz_down && ImGui::IsMouseDown(ImGuiMouseButton_Left);
in.clicked = !_giz_down && hovered &&
ImGui::IsMouseClicked(ImGuiMouseButton_Left);
in.released = !_giz_down && ImGui::IsMouseReleased(ImGuiMouseButton_Left);
in.right_clicked = hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Right);
in.double_clicked = hovered &&
ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left);
@@ -590,6 +797,9 @@ void ViewportPanel::handle_input(float /*item_h*/) {
for (int b : {ImGuiMouseButton_Left, ImGuiMouseButton_Right,
ImGuiMouseButton_Middle}) {
if (b == ImGuiMouseButton_Left && tool_owns_left) continue;
if (b == ImGuiMouseButton_Right && _interactor &&
_interactor->owns_right_button())
continue;
if (ImGui::IsMouseClicked(b)) {
_dragging = true;
_drag_button = b;
@@ -617,13 +827,19 @@ void ViewportPanel::handle_input(float /*item_h*/) {
_drag_button, (int)is_pan, (int)io.KeyShift, dx, dy,
_cam.target[0], _cam.target[1], _cam.target[2],
_cam.pos[0], _cam.pos[1], _cam.pos[2]);
if (is_pan)
if (is_pan) {
_cam.pan(dx, dy);
else if (_cam.mode == NavCamera::Turntable ||
_cam.mode == NavCamera::Trackball)
_cam.orbit(dx, dy);
else
_cam.look(dx, dy);
} else {
if (_cam.mode == NavCamera::Turntable ||
_cam.mode == NavCamera::Trackball)
_cam.orbit(dx, dy);
else
_cam.look(dx, dy);
// An axis view is orthographic because it is an axis
// view; turned away from the axis it is a view again.
if (_ortho_auto) _ortho = _ortho_auto = false;
_anim = false;
}
_dirty = true;
}
}
@@ -648,7 +864,16 @@ void ViewportPanel::handle_input(float /*item_h*/) {
// Scroll = dolly (browser wheel deltaY is ~+-100 per notch, ImGui is
// +-1 with the opposite sign convention).
if (hovered && io.MouseWheel != 0.0f) {
_cam.dolly(-io.MouseWheel * 100.0f);
if (ortho_back() > 0.0f) {
// Moving forward changes nothing about an orthographic image, so
// every mode zooms the way the orbiting ones do.
const float k = std::exp(-io.MouseWheel * 100.0f * 0.004f *
_cam.speed() * 0.2f);
for (int i = 0; i < 3; i++)
_cam.pos[i] = _cam.target[i] + (_cam.pos[i] - _cam.target[i]) * k;
} else {
_cam.dolly(-io.MouseWheel * 100.0f);
}
_dirty = true;
}
@@ -682,6 +907,14 @@ void ViewportPanel::handle_input(float /*item_h*/) {
float dt = std::min(io.DeltaTime, 0.1f);
if (_cam.keyboard_tick(dt, k)) _dirty = true;
}
// The numeric-pad views every 3D package shares: 1 front, 3 right, 7 top,
// Ctrl for the far side, 5 for perspective / orthographic.
if (hovered && !io.WantTextInput && !io.KeyAlt && !io.KeyShift) {
if (ImGui::IsKeyPressed(ImGuiKey_Keypad1, false)) snap_view(1, !io.KeyCtrl);
if (ImGui::IsKeyPressed(ImGuiKey_Keypad3, false)) snap_view(0, io.KeyCtrl);
if (ImGui::IsKeyPressed(ImGuiKey_Keypad7, false)) snap_view(2, io.KeyCtrl);
if (ImGui::IsKeyPressed(ImGuiKey_Keypad5, false)) set_ortho(!_ortho);
}
// Gamepad: always active, like the browser's gamepadTick loop.
{
@@ -690,6 +923,268 @@ void ViewportPanel::handle_input(float /*item_h*/) {
}
}
// ---------------------------------------------------------------------------
// The navigation gizmo
// ---------------------------------------------------------------------------
// For a pointer with no middle button, and it works with a tool active --
// which is exactly when the left button is otherwise spoken for.
namespace {
struct GizmoLayout {
ImVec2 c; // ball centre
float R = 0; // ball radius
float rb = 0; // side-button radius
ImVec2 btn[3]; // zoom, pan, projection
bool shown = false;
};
GizmoLayout gizmo_layout(float x, float y, float w, float h) {
GizmoLayout g;
g.R = px(38.0f);
g.rb = px(13.0f);
const float m = px(10.0f);
g.shown = w > 5.0f * g.R && h > 6.0f * g.R;
g.c = ImVec2(x + w - g.R - m, y + g.R + m);
for (int i = 0; i < 3; i++)
g.btn[i] = ImVec2(x + w - m - g.rb,
g.c.y + g.R + m + g.rb + (float)i * (2.0f * g.rb + px(6.0f)));
return g;
}
// Where axis `a` (0..5: +X +Y +Z -X -Y -Z) lands: x right, y down, z toward
// the viewer, in units of the ball radius.
void gizmo_axis(const NavCamera& cam, int a, float out[3]) {
float r[3], u[3], f[3];
cam.axis_right(r);
cam.axis_up(u);
cam.axis_forward(f);
const int k = a % 3;
const float sgn = a < 3 ? 1.0f : -1.0f;
out[0] = sgn * r[k];
out[1] = -sgn * u[k];
out[2] = -sgn * f[k];
}
constexpr ImU32 kAxisCol[3] = {IM_COL32(250, 51, 79, 255),
IM_COL32(140, 219, 0, 255),
IM_COL32(41, 140, 250, 255)};
} // namespace
bool ViewportPanel::gizmo_input(bool hovered_image) {
const GizmoLayout g = gizmo_layout(_img_x, _img_y, _img_w, _img_h);
if (!g.shown) {
_giz_down = _giz_hover = false;
_giz_hot = -1;
return false;
}
ImGuiIO& io = ImGui::GetIO();
const ImVec2 mp = io.MousePos;
auto within = [&](const ImVec2& c, float r) {
const float dx = mp.x - c.x, dy = mp.y - c.y;
return dx * dx + dy * dy <= r * r;
};
if (!_giz_down) {
_giz_hot = -1;
_giz_hover = false;
if (hovered_image || ImGui::IsWindowHovered()) {
for (int i = 0; i < 3; i++)
if (within(g.btn[i], g.rb)) _giz_hot = 6 + i;
if (_giz_hot < 0 && within(g.c, g.R + px(6.0f))) {
_giz_hover = true;
// The bubble nearest the viewer wins where two overlap.
float best_z = -2.0f;
for (int a = 0; a < 6; a++) {
float v[3];
gizmo_axis(_cam, a, v);
const ImVec2 at(g.c.x + v[0] * g.R * 0.78f,
g.c.y + v[1] * g.R * 0.78f);
if (within(at, px(a < 3 ? 10.0f : 8.0f)) && v[2] > best_z) {
best_z = v[2];
_giz_hot = a;
}
}
}
}
if ((_giz_hover || _giz_hot >= 0) &&
ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
_giz_down = true;
_giz_dragged = false;
_giz_button = _giz_hot >= 6 ? _giz_hot - 5 : 0;
_giz_press[0] = mp.x;
_giz_press[1] = mp.y;
}
return _giz_hover || _giz_hot >= 0;
}
if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
if (!_giz_dragged) {
if (_giz_button == 3) {
set_ortho(!_ortho);
} else if (_giz_button == 0 && _giz_hot >= 0 && _giz_hot < 6) {
// Already looking along it: a second click is the far side.
float v[3];
gizmo_axis(_cam, _giz_hot, v);
const bool facing = v[2] > 0.999f;
snap_view(_giz_hot % 3, (_giz_hot >= 3) != facing);
}
}
_giz_down = false;
_giz_button = 0;
return true;
}
const float ddx = mp.x - _giz_press[0], ddy = mp.y - _giz_press[1];
if (ddx * ddx + ddy * ddy > 16.0f) _giz_dragged = true;
const float dx = io.MouseDelta.x, dy = io.MouseDelta.y;
if (_giz_dragged && (dx != 0.0f || dy != 0.0f)) {
if (_giz_button == 1) {
// Down is closer, the way a scroll toward you is.
const float k = std::exp(-dy * 0.01f);
for (int i = 0; i < 3; i++)
_cam.pos[i] = _cam.target[i] + (_cam.pos[i] - _cam.target[i]) * k;
} else if (_giz_button == 2) {
_cam.pan(dx * 2.0f, dy * 2.0f);
} else if (_giz_button == 0) {
if (_cam.mode == NavCamera::Turntable || _cam.mode == NavCamera::Trackball)
_cam.orbit(dx * 1.5f, dy * 1.5f);
else
_cam.look(dx * 1.5f, dy * 1.5f);
if (_ortho_auto) _ortho = _ortho_auto = false;
_anim = false;
}
_dirty = true;
}
return true;
}
void ViewportPanel::draw_gizmo() const {
const GizmoLayout g = gizmo_layout(_img_x, _img_y, _img_w, _img_h);
if (!g.shown) return;
ImDrawList* dl = ImGui::GetWindowDrawList();
if (_giz_hover || (_giz_down && _giz_button == 0))
dl->AddCircleFilled(g.c, g.R + px(4.0f), IM_COL32(255, 255, 255, 38), 48);
int order[6] = {0, 1, 2, 3, 4, 5};
float z[6];
ImVec2 at[6];
for (int a = 0; a < 6; a++) {
float v[3];
gizmo_axis(_cam, a, v);
z[a] = v[2];
at[a] = ImVec2(g.c.x + v[0] * g.R * 0.78f, g.c.y + v[1] * g.R * 0.78f);
}
std::sort(order, order + 6, [&](int a, int b) { return z[a] < z[b]; });
const char* names[3] = {"X", "Y", "Z"};
for (int a : order) {
const int k = a % 3;
const bool hot = _giz_hot == a;
// Dimmed toward the back, so the ball reads as a ball.
const float shade = 0.55f + 0.45f * (z[a] * 0.5f + 0.5f);
ImVec4 c = ImGui::ColorConvertU32ToFloat4(kAxisCol[k]);
c.x *= shade; c.y *= shade; c.z *= shade;
const ImU32 col = ImGui::ColorConvertFloat4ToU32(c);
if (a < 3) {
dl->AddLine(g.c, at[a], col, px(2.0f));
dl->AddCircleFilled(at[a], px(hot ? 10.0f : 9.0f), col, 24);
const ImVec2 ts = ImGui::CalcTextSize(names[k]);
dl->AddText(ImVec2(at[a].x - ts.x * 0.5f, at[a].y - ts.y * 0.5f),
hot ? IM_COL32(255, 255, 255, 255) : IM_COL32(0, 0, 0, 230),
names[k]);
} else {
ImVec4 fill = c;
fill.w = hot ? 0.85f : 0.35f;
dl->AddCircleFilled(at[a], px(7.0f), ImGui::ColorConvertFloat4ToU32(fill), 20);
dl->AddCircle(at[a], px(7.0f), col, 20, px(1.5f));
}
}
// zoom, pan, projection -- drawn, since no icon face is embedded.
for (int i = 0; i < 3; i++) {
const bool hot = _giz_hot == 6 + i || (_giz_down && _giz_button == i + 1);
const ImVec2 c = g.btn[i];
dl->AddCircleFilled(c, g.rb, hot ? IM_COL32(255, 255, 255, 70)
: IM_COL32(20, 22, 26, 170), 24);
const ImU32 ink = IM_COL32(235, 235, 235, 235);
const float u = g.rb * 0.5f, t = px(1.6f);
if (i == 0) {
dl->AddCircle(ImVec2(c.x - u * 0.2f, c.y - u * 0.2f), u * 0.75f, ink, 16, t);
dl->AddLine(ImVec2(c.x + u * 0.35f, c.y + u * 0.35f),
ImVec2(c.x + u, c.y + u), ink, t * 1.3f);
} else if (i == 1) {
dl->AddLine(ImVec2(c.x - u, c.y), ImVec2(c.x + u, c.y), ink, t);
dl->AddLine(ImVec2(c.x, c.y - u), ImVec2(c.x, c.y + u), ink, t);
const float a = u * 0.35f;
for (int d = 0; d < 4; d++) {
const float ex = d == 0 ? -u : d == 1 ? u : 0.0f;
const float ey = d == 2 ? -u : d == 3 ? u : 0.0f;
const ImVec2 tip(c.x + ex, c.y + ey);
const float bx = ex == 0 ? a : (ex < 0 ? a : -a);
const float by = ey == 0 ? a : (ey < 0 ? a : -a);
if (ex != 0) {
dl->AddLine(tip, ImVec2(tip.x + bx, tip.y - a), ink, t);
dl->AddLine(tip, ImVec2(tip.x + bx, tip.y + a), ink, t);
} else {
dl->AddLine(tip, ImVec2(tip.x - a, tip.y + by), ink, t);
dl->AddLine(tip, ImVec2(tip.x + a, tip.y + by), ink, t);
}
}
} else if (ortho_back() > 0.0f) {
// Parallel edges: a square grid.
dl->AddRect(ImVec2(c.x - u, c.y - u), ImVec2(c.x + u, c.y + u), ink, 0.0f, 0, t);
dl->AddLine(ImVec2(c.x, c.y - u), ImVec2(c.x, c.y + u), ink, t);
dl->AddLine(ImVec2(c.x - u, c.y), ImVec2(c.x + u, c.y), ink, t);
} else {
// Converging edges: the same grid seen in perspective.
const ImVec2 q[4] = {ImVec2(c.x - u * 0.55f, c.y - u * 0.8f),
ImVec2(c.x + u * 0.55f, c.y - u * 0.8f),
ImVec2(c.x + u, c.y + u * 0.8f),
ImVec2(c.x - u, c.y + u * 0.8f)};
dl->AddPolyline(q, 4, ink, ImDrawFlags_Closed, t);
dl->AddLine(ImVec2(c.x, q[0].y), ImVec2(c.x, q[2].y), ink, t);
dl->AddLine(ImVec2(c.x - u * 0.78f, c.y), ImVec2(c.x + u * 0.78f, c.y), ink, t);
}
}
// Not an ImGui item, so the usual hover delay is kept by hand: a tooltip
// that opens the instant the pointer crosses the ball covers it.
const int on = _giz_down ? -2 : _giz_hot >= 6 ? _giz_hot : _giz_hover ? -1 : -2;
if (on != _giz_tip_on) {
_giz_tip_on = on;
_giz_tip_since = ImGui::GetTime();
}
if (on != -2 && ImGui::GetTime() - _giz_tip_since > 0.6) {
if (_giz_hot == 6) ui::SetTooltip(msg::gizmo_zoom_help);
else if (_giz_hot == 7) ui::SetTooltip(msg::gizmo_pan_help);
else if (_giz_hot == 8)
ui::SetTooltip(ortho_back() > 0.0f ? msg::gizmo_to_perspective
: msg::gizmo_to_ortho);
else if (_giz_hover) ui::SetTooltip(msg::gizmo_help);
}
}
// What is drawn over the image in either mode: the tool's overlay, then the
// gizmo on top of it.
void ViewportPanel::draw_overlays() {
if (_interactor) {
ViewportOverlay ov;
ov.dl = ImGui::GetWindowDrawList();
ov.x = _img_x;
ov.y = _img_y;
ov.w = _img_w;
ov.h = _img_h;
ov.grid = _show_grid && external_grid();
ov.grid_cell = world_grid_cell();
ov.dl->PushClipRect(ImVec2(_img_x, _img_y),
ImVec2(_img_x + _img_w, _img_y + _img_h), true);
_interactor->draw_viewport_overlay(ov);
ov.dl->PopClipRect();
}
draw_gizmo();
}
// ---------------------------------------------------------------------------
// Draw
// ---------------------------------------------------------------------------
@@ -1052,7 +1547,9 @@ void ViewportPanel::draw_preview(const ImVec2& avail) {
(PreviewProjection)_cam_model,
fx / (0.5f * W), fy / (0.5f * H),
_home_dist, nav_dist() / _m2s_scale, target,
_show_cams, _frustum_scale, _show_grid);
_show_cams, _frustum_scale,
_show_grid && !external_grid(),
ortho_pullback(false));
if (!tex) {
ui::TextDisabled(msg::viewport_render_failed);
return;
@@ -1084,15 +1581,7 @@ void ViewportPanel::draw_preview(const ImVec2& avail) {
}
}
if (_interactor) {
ViewportOverlay ov;
ov.dl = ImGui::GetWindowDrawList();
ov.x = _img_x;
ov.y = _img_y;
ov.w = _img_w;
ov.h = _img_h;
_interactor->draw_viewport_overlay(ov);
}
draw_overlays();
// A count and what is being counted, which depends on what is being
// previewed. Labelled rather than inflected ("Triangles: 12", not
@@ -1127,10 +1616,11 @@ void ViewportPanel::draw_preview(const ImVec2& avail) {
// must not look idle.
void ViewportPanel::note_motion(double now) {
constexpr double kSettle = 0.25; // seconds of stillness before full res
float pose[10];
float pose[11];
for (int i = 0; i < 3; i++) pose[i] = _cam.pos[i];
for (int i = 0; i < 4; i++) pose[3 + i] = _cam.rot[i];
for (int i = 0; i < 3; i++) pose[7 + i] = _cam.target[i];
pose[10] = _ortho ? 1.0f : 0.0f;
_moved_last_draw = std::memcmp(pose, _last_pose, sizeof pose) != 0;
if (_moved_last_draw) {
std::memcpy(_last_pose, pose, sizeof pose);
@@ -1152,11 +1642,13 @@ void ViewportPanel::note_motion(double now) {
void ViewportPanel::sync_view_from(const ViewportPanel& src) {
if (&src == this) return;
if (std::memcmp(&_cam, &src._cam, sizeof(NavCamera)) == 0 &&
_cam_model == src._cam_model &&
_cam_model == src._cam_model && _ortho == src._ortho &&
_fov_deg[_cam_model] == src._fov_deg[src._cam_model])
return;
_cam = src._cam;
_cam_model = src._cam_model;
_ortho = src._ortho;
_ortho_auto = src._ortho_auto;
for (int i = 0; i < 4; i++) _fov_deg[i] = src._fov_deg[i];
_home = src._home;
_home_dist = src._home_dist;
@@ -1298,15 +1790,7 @@ void ViewportPanel::draw_engine(bool training, const ImVec2& avail, int step) {
const ImVec2 tl = ImGui::GetItemRectMin();
draw_grid_overlay(tl.x + 8, tl.y + 6, 0);
handle_input(size.y);
if (_interactor) {
ViewportOverlay ov;
ov.dl = ImGui::GetWindowDrawList();
ov.x = _img_x;
ov.y = _img_y;
ov.w = _img_w;
ov.h = _img_h;
_interactor->draw_viewport_overlay(ov);
}
draw_overlays();
} else {
ImGui::Dummy(ImVec2(avail.x, avail.y * 0.4f));
const char* line = _last_error.empty() ? msg::viewport_rendering.get()
+63 -1
View File
@@ -107,8 +107,42 @@ public:
int& camera_model, float eye[3]) const;
// Where the last draw put the image on screen, in ImGui coordinates.
void image_rect(float& x, float& y, float& w, float& h) const;
// The same pose in the SHARED frame the camera navigates, which is where
// a placement is dragged: the model moves through it, the grid does not.
void nav_camera(int W, int H, float w2c[12], float& fx, float& fy,
int& camera_model, float eye[3]) const;
// How far the orthographic emulation pulled the render camera back along
// its axis, in the frame view_camera / nav_camera report; 0 in perspective.
float ortho_pullback(bool shared) const;
// The orbit pivot, shared frame: what the view is looking at.
void nav_target(float out[3]) const {
for (int i = 0; i < 3; i++) out[i] = _cam.target[i];
}
// A render is due: what a tool calls after changing what is drawn.
void invalidate() { _dirty = true; }
// A placement under edit, model frame -> model frame, composed INSIDE the
// owner's: what the editor moves while the owner's alignment stays put.
void set_edit_transform(const float a[12]);
// Model -> shared with no edit applied: the frame a placement is made in.
void base_transform(float out[12]) const;
float world_grid_cell() const;
// The parsers' up->+Z guess (adopt_gauge). Placing a model means seeing
// the axes that get SAVED, which is with the guess switched off.
bool has_levelling() const { return !_align_identity; }
bool level_cameras() const { return _level_cameras; }
void set_level_cameras(bool on);
// Take the view along with a step of the shared frame (row-major 3x4
// similarity), then stand it upright again: the model stays where it was
// on screen and it is the grid that arrives under it.
void carry_view(const float step[12]);
// Look along a world axis (0..2, `negative` for the far side), switching
// to the orthographic view; and the switch on its own.
void snap_view(int axis, bool negative);
bool ortho() const { return _ortho; }
void set_ortho(bool on);
// Where the centring menu's points come from when the user PICKS one, so
// an edited model centres on what is left of it. Asked only on the pick:
// a median per frame is a hiccup, and a centre is where you asked for it.
@@ -256,6 +290,34 @@ private:
// The grid's cell in model units, from the same rule both backends use.
float grid_cell() const;
void draw_grid_overlay(float x, float y, int line) const;
// The navigation gizmo in the image's corner: drag to orbit, click an
// axis to look along it. True while it has the pointer.
bool gizmo_input(bool hovered_image);
void draw_gizmo() const;
void draw_overlays();
void animate_view(double now);
bool external_grid() const;
// Camera-to-world in the shared frame, pulled back when orthographic.
void render_c2w(float out[12]) const;
float ortho_back() const;
// Orthographic is a pinhole a long way off with a long lens: every
// renderer, primitive and selection test then works unchanged.
bool _ortho = false;
bool _ortho_auto = false; // entered by an axis click: orbit leaves it
// A view change in flight (axis snap): rotation slerped, pivot distance kept.
bool _anim = false;
double _anim_t0 = 0.0;
float _anim_from[4] = {0, 0, 0, 1}, _anim_to[4] = {0, 0, 0, 1};
// Gizmo pointer state.
bool _giz_down = false, _giz_dragged = false, _giz_hover = false;
int _giz_hot = -1; // 0..5: +X +Y +Z -X -Y -Z under the cursor
int _giz_button = 0; // 0 none, 1 pan, 2 zoom (the side buttons)
float _giz_press[2] = {0, 0};
mutable int _giz_tip_on = -2; // what the tooltip timer is running for
mutable double _giz_tip_since = 0.0;
float _m2s_edit[12] = {1,0,0,0, 0,1,0,0, 0,0,1,0};
float _m2s_base[12] = {1,0,0,0, 0,1,0,0, 0,0,1,0};
ViewportInteractor* _interactor = nullptr;
std::function<bool(dsparse::CenterTable&)> _center_provider;
// The image rectangle of the last draw, which is the frame a tool's
@@ -293,7 +355,7 @@ private:
float _frustum_scale = 1.0f; // camera-frustum size multiplier
// 0 = auto (see render_scale), 1 = 50%, 2 = 75%, 3 = 100%
int _scale_idx = 0;
float _last_pose[10] = {}; // pos + rot + target, to spot motion
float _last_pose[11] = {}; // pos + rot + target + ortho, to spot motion
// The pose (or camera model / FOV) changed during the last draw. Drives
// the side-by-side link; note_motion sets it, draw clears it.
bool _moved_last_draw = false;
+494
View File
@@ -0,0 +1,494 @@
// AlignFit.cpp -- see AlignFit.h.
#include "app/gui/edit/AlignFit.h"
#include <algorithm>
#include <cmath>
#include <numeric>
#include <random>
namespace gui {
namespace align {
namespace {
// RANSAC scores a hypothesis over at most this many points: the winner is
// then recounted over all of them, so the cap costs accuracy nowhere.
constexpr int64_t kScoreCap = 40000;
constexpr int kIterations = 500;
double dot(const double a[3], const double b[3]) {
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
}
void cross(const double a[3], const double b[3], double o[3]) {
o[0] = a[1]*b[2] - a[2]*b[1];
o[1] = a[2]*b[0] - a[0]*b[2];
o[2] = a[0]*b[1] - a[1]*b[0];
}
bool normalize(double v[3]) {
const double l = std::sqrt(dot(v, v));
if (!(l > 1e-300)) return false;
for (int i = 0; i < 3; i++) v[i] /= l;
return true;
}
// Smallest-eigenvalue eigenvector of a symmetric 3x3 by cyclic Jacobi.
void smallest_eigenvector(double A[9], double out[3]) {
double V[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1};
for (int sweep = 0; sweep < 32; sweep++) {
double off = A[1]*A[1] + A[2]*A[2] + A[5]*A[5];
if (off < 1e-30) break;
for (int p = 0; p < 2; p++)
for (int q = p + 1; q < 3; q++) {
const double apq = A[p*3+q];
if (std::fabs(apq) < 1e-300) continue;
const double th = (A[q*3+q] - A[p*3+p]) / (2.0 * apq);
const double t = (th >= 0 ? 1.0 : -1.0) /
(std::fabs(th) + std::sqrt(th*th + 1.0));
const double c = 1.0 / std::sqrt(t*t + 1.0), s = t * c;
for (int k = 0; k < 3; k++) {
const double akp = A[k*3+p], akq = A[k*3+q];
A[k*3+p] = c*akp - s*akq;
A[k*3+q] = s*akp + c*akq;
}
for (int k = 0; k < 3; k++) {
const double apk = A[p*3+k], aqk = A[q*3+k];
A[p*3+k] = c*apk - s*aqk;
A[q*3+k] = s*apk + c*aqk;
}
for (int k = 0; k < 3; k++) {
const double vkp = V[k*3+p], vkq = V[k*3+q];
V[k*3+p] = c*vkp - s*vkq;
V[k*3+q] = s*vkp + c*vkq;
}
}
}
int lo = 0;
if (A[4] < A[lo*4]) lo = 1;
if (A[8] < A[lo*4]) lo = 2;
for (int k = 0; k < 3; k++) out[k] = V[k*3+lo];
}
// Least squares over the flagged points. Keeps the normal's side.
bool refit(const double* pts, int64_t n, const std::vector<uint8_t>& in,
Plane& pl) {
double c[3] = {0, 0, 0};
int64_t m = 0;
for (int64_t i = 0; i < n; i++) {
if (!in[(size_t)i]) continue;
for (int k = 0; k < 3; k++) c[k] += pts[i*3+k];
m++;
}
if (m < 3) return false;
for (int k = 0; k < 3; k++) c[k] /= (double)m;
double A[9] = {0};
for (int64_t i = 0; i < n; i++) {
if (!in[(size_t)i]) continue;
const double d[3] = {pts[i*3]-c[0], pts[i*3+1]-c[1], pts[i*3+2]-c[2]};
for (int r = 0; r < 3; r++)
for (int q = 0; q < 3; q++) A[r*3+q] += d[r] * d[q];
}
double nrm[3];
smallest_eigenvector(A, nrm);
if (!normalize(nrm)) return false;
if (dot(nrm, pl.n) < 0)
for (double& v : nrm) v = -v;
for (int k = 0; k < 3; k++) pl.n[k] = nrm[k];
pl.d = -dot(nrm, c);
return true;
}
int64_t mark_inliers(const double* pts, int64_t n, const Plane& pl, double tol,
std::vector<uint8_t>& in) {
in.assign((size_t)n, 0);
int64_t m = 0;
for (int64_t i = 0; i < n; i++)
if (std::fabs(pl.distance(pts + i*3)) <= tol) {
in[(size_t)i] = 1;
m++;
}
return m;
}
} // namespace
bool fit_plane(const double* pts, int64_t n, double tol, uint32_t seed,
Plane& out, std::vector<uint8_t>* inlier) {
if (n < 3) return false;
std::mt19937 rng(seed);
const int64_t step = std::max<int64_t>(1, n / kScoreCap);
Plane best;
int64_t best_count = 0;
for (int it = 0; it < kIterations; it++) {
const double* a = pts + 3 * (int64_t)(rng() % (uint64_t)n);
const double* b = pts + 3 * (int64_t)(rng() % (uint64_t)n);
const double* c = pts + 3 * (int64_t)(rng() % (uint64_t)n);
const double e1[3] = {b[0]-a[0], b[1]-a[1], b[2]-a[2]};
const double e2[3] = {c[0]-a[0], c[1]-a[1], c[2]-a[2]};
Plane h;
cross(e1, e2, h.n);
if (!normalize(h.n)) continue;
h.d = -dot(h.n, a);
int64_t count = 0;
for (int64_t i = 0; i < n; i += step)
if (std::fabs(h.distance(pts + i*3)) <= tol) count++;
if (count > best_count) {
best_count = count;
best = h;
}
}
if (best_count < 3) return false;
std::vector<uint8_t> in;
// Two rounds: the first refit moves the plane, which moves who is on it.
for (int round = 0; round < 2; round++) {
if (mark_inliers(pts, n, best, tol, in) < 3) return false;
if (!refit(pts, n, in, best)) return false;
}
best.inliers = mark_inliers(pts, n, best, tol, in);
out = best;
if (inlier) inlier->swap(in);
return best.inliers >= 3;
}
std::vector<Plane> find_planes(const double* pts, int64_t n, double tol, int k,
double min_frac) {
std::vector<Plane> out;
std::vector<double> rest(pts, pts + n * 3);
for (int i = 0; i < k; i++) {
const int64_t m = (int64_t)rest.size() / 3;
Plane pl;
std::vector<uint8_t> in;
if (!fit_plane(rest.data(), m, tol, 977u + (uint32_t)i, pl, &in)) break;
if ((double)pl.inliers < min_frac * (double)n) break;
out.push_back(pl);
std::vector<double> next;
next.reserve(rest.size());
for (int64_t j = 0; j < m; j++)
if (!in[(size_t)j])
next.insert(next.end(), rest.begin() + j*3, rest.begin() + j*3 + 3);
rest.swap(next);
}
return out;
}
// Least quantile of squares THROUGH the click: smallest 35th-percentile
// residual. No tolerance to choose (a lawn is centimetres thick, a tabletop is
// not), and a wall that outnumbers the floor still misses the click.
static bool fit_plane_lqs(const double* pts, int64_t n, const double at[3],
uint32_t seed, Plane& out) {
if (n < 8) return false;
std::mt19937 rng(seed);
const int64_t step = std::max<int64_t>(1, n / 4000);
std::vector<double> res;
res.reserve((size_t)(n / step + 1));
double best = -1.0;
for (int it = 0; it < kIterations; it++) {
const double* b = pts + 3 * (int64_t)(rng() % (uint64_t)n);
const double* c = pts + 3 * (int64_t)(rng() % (uint64_t)n);
const double e1[3] = {b[0]-at[0], b[1]-at[1], b[2]-at[2]};
const double e2[3] = {c[0]-at[0], c[1]-at[1], c[2]-at[2]};
Plane h;
cross(e1, e2, h.n);
if (!normalize(h.n)) continue;
h.d = -dot(h.n, at);
res.clear();
for (int64_t i = 0; i < n; i += step) res.push_back(std::fabs(h.distance(pts + i*3)));
const size_t k = res.size() * 35 / 100;
std::nth_element(res.begin(), res.begin() + (ptrdiff_t)k, res.end());
if (best < 0 || res[k] < best) {
best = res[k];
out = h;
}
}
return best >= 0;
}
bool fit_plane_at(const double* pts, int64_t n, const double at[3], double r0,
Plane& out) {
auto within = [&](double r, const Plane* slab, double slab_tol) {
std::vector<double> sub;
const double r2 = r * r;
for (int64_t i = 0; i < n; i++) {
const double d[3] = {pts[i*3]-at[0], pts[i*3+1]-at[1], pts[i*3+2]-at[2]};
if (dot(d, d) > r2) continue;
if (slab && std::fabs(slab->distance(pts + i*3)) > slab_tol) continue;
sub.insert(sub.end(), pts + i*3, pts + i*3 + 3);
}
return sub;
};
// The first patch has to hold enough of the surface to have a normal.
std::vector<double> sub;
double r = r0;
for (int tries = 0; tries < 6; tries++, r *= 1.6) {
sub = within(r, nullptr, 0.0);
if ((int64_t)sub.size() / 3 >= 200) break;
}
if ((int64_t)sub.size() / 3 < 8) return false;
Plane pl;
if (!fit_plane_lqs(sub.data(), (int64_t)sub.size() / 3, at, 31u, pl)) return false;
// How thick the surface itself is, from the patch that found it: the slab
// below is sized by the data's own noise, not by the radius.
auto thickness = [&](const std::vector<double>& p, const Plane& q) {
std::vector<double> res;
res.reserve(p.size() / 3);
for (size_t i = 0; i + 2 < p.size(); i += 3)
res.push_back(std::fabs(q.distance(&p[i])));
if (res.empty()) return 0.0;
// The 35th percentile of |N(0,1)| is 0.454: the same quantile the fit
// was scored on, so clutter that outnumbers the surface is not in it.
const size_t k = res.size() * 35 / 100;
std::nth_element(res.begin(), res.begin() + (ptrdiff_t)k, res.end());
return res[k] / 0.454;
};
auto tight_refit = [&](const std::vector<double>& p, Plane& q, double tol) {
const int64_t m = (int64_t)p.size() / 3;
std::vector<uint8_t> in;
if (mark_inliers(p.data(), m, q, tol, in) < 8) return false;
return refit(p.data(), m, in, q);
};
double sigma = std::max(thickness(sub, pl), 1e-9 * r);
tight_refit(sub, pl, 2.5 * sigma);
tight_refit(sub, pl, 2.5 * sigma);
{
std::vector<uint8_t> in;
mark_inliers(sub.data(), (int64_t)sub.size() / 3, pl, 2.5 * sigma, in);
std::vector<double> on;
for (size_t i = 0; i < in.size(); i++)
if (in[i]) on.insert(on.end(), sub.begin() + i*3, sub.begin() + i*3 + 3);
sub.swap(on);
pl.inliers = (int64_t)sub.size() / 3;
}
// Wider while it holds. The slab keeps what is off the surface -- the
// chair standing on the floor -- out of the refit.
int64_t support = (int64_t)sub.size() / 3;
for (int grow = 0; grow < 4; grow++) {
const double r2 = r * 2.0;
std::vector<double> wide = within(r2, &pl, 4.0 * sigma + 0.01 * r2);
const int64_t m = (int64_t)wide.size() / 3;
if (m < support * 2) break;
Plane next = pl;
if (!tight_refit(wide, next, 3.0 * sigma)) break;
if (!tight_refit(wide, next, 3.0 * sigma)) break;
// A surface that curves away is a different surface.
if (dot(next.n, pl.n) < 0.985) break;
std::vector<uint8_t> in;
next.inliers = mark_inliers(wide.data(), m, next, 3.0 * sigma, in);
pl = next;
support = m;
r = r2;
}
out = pl;
return true;
}
void rotation_between(const double a[3], const double b[3], double R[9]) {
double axis[3];
cross(a, b, axis);
const double c = std::clamp(dot(a, b), -1.0, 1.0);
if (!normalize(axis)) {
// Parallel: nothing to do. Opposite: half a turn about anything
// perpendicular.
for (int i = 0; i < 9; i++) R[i] = i % 4 == 0 ? 1.0 : 0.0;
if (c > 0) return;
double other[3] = {1, 0, 0};
if (std::fabs(a[0]) > 0.9) { other[0] = 0; other[1] = 1; }
cross(a, other, axis);
normalize(axis);
for (int r = 0; r < 3; r++)
for (int q = 0; q < 3; q++)
R[r*3+q] = 2.0 * axis[r] * axis[q] - (r == q ? 1.0 : 0.0);
return;
}
const double pivot[3] = {0, 0, 0};
const spirula::Sim3 T =
spirula::Sim3::rotation_about(axis, std::acos(c), pivot);
for (int i = 0; i < 9; i++) R[i] = T.R[i];
}
int fit_corner(const double* pts, int64_t n, const double at[3], double r0,
double axes[9], double corner[3]) {
std::vector<double> sub;
double r = r0;
for (int tries = 0; tries < 6; tries++, r *= 1.5) {
sub.clear();
const double r2 = r * r;
for (int64_t i = 0; i < n; i++) {
const double d[3] = {pts[i*3]-at[0], pts[i*3+1]-at[1], pts[i*3+2]-at[2]};
if (dot(d, d) <= r2) sub.insert(sub.end(), pts + i*3, pts + i*3 + 3);
}
if ((int64_t)sub.size() / 3 >= 300) break;
}
const std::vector<Plane> found =
find_planes(sub.data(), (int64_t)sub.size() / 3, 0.03 * r, 5, 0.06);
// Greedily: the largest, then the largest roughly square to those kept.
std::vector<Plane> keep;
for (const Plane& p : found) {
bool square = true;
for (const Plane& k : keep)
if (std::fabs(dot(p.n, k.n)) > 0.35) square = false;
if (square) keep.push_back(p);
if (keep.size() == 3) break;
}
const int m = (int)keep.size();
for (int i = 0; i < 3; i++) corner[i] = at[i];
for (int i = 0; i < 9; i++) axes[i] = i % 4 == 0 ? 1.0 : 0.0;
if (m == 0) return 0;
// Orthonormalize, most trusted first; complete the frame by cross products.
double e[3][3];
for (int k = 0; k < 3; k++) e[0][k] = keep[0].n[k];
if (m >= 2) {
const double d = dot(keep[1].n, e[0]);
for (int k = 0; k < 3; k++) e[1][k] = keep[1].n[k] - d * e[0][k];
normalize(e[1]);
} else {
double other[3] = {0, 0, 1};
if (std::fabs(e[0][2]) > 0.9) { other[2] = 0; other[0] = 1; }
cross(other, e[0], e[1]);
normalize(e[1]);
}
cross(e[0], e[1], e[2]);
if (m >= 3 && dot(e[2], keep[2].n) < 0)
for (double& v : e[2]) v = -v;
for (int r = 0; r < 3; r++)
for (int k = 0; k < 3; k++) axes[r*3+k] = e[r][k];
// Where they meet: the click pushed onto each plane found, in turn. For
// perpendicular planes one pass lands on all of them.
for (int pass = 0; pass < 4; pass++)
for (int i = 0; i < m; i++) {
const double d = keep[(size_t)i].distance(corner);
for (int k = 0; k < 3; k++) corner[k] -= d * keep[(size_t)i].n[k];
}
return m;
}
AutoAlignResult auto_align(const double* pts, int64_t n, const double* up,
const float* normals, const float* weights,
const AutoAlignOptions& opt) {
AutoAlignResult res;
if (n < 8) return res;
double prior[3] = {0, 0, 1};
if (up) {
for (int k = 0; k < 3; k++) prior[k] = up[k];
if (!normalize(prior)) { prior[0] = prior[1] = 0; prior[2] = 1; }
}
const std::vector<Plane> planes = find_planes(pts, n, opt.tol, 6, 0.03);
// The ground: well supported, facing up, and with the scene on top of it
// rather than under it -- which is what tells a floor from a ceiling and,
// more often, a floor from the largest wall.
int best = -1;
double best_score = 0.0;
std::vector<Plane> oriented = planes;
// A prior that came from cameras is evidence; +Z is only what the file
// happens to say, and a model that arrived on its side says it wrongly.
const bool trusted = up != nullptr;
for (size_t i = 0; i < oriented.size(); i++) {
Plane& p = oriented[i];
int64_t above = 0, below = 0;
const int64_t step = std::max<int64_t>(1, n / kScoreCap);
for (int64_t j = 0; j < n; j += step) {
const double d = p.distance(pts + j*3);
if (d > 2.0 * opt.tol) above++;
else if (d < -2.0 * opt.tol) below++;
}
// Up is the side the scene is on -- unless cameras said otherwise.
const bool flip = trusted ? dot(p.n, prior) < 0 : below > above;
if (flip) {
for (double& v : p.n) v = -v;
p.d = -p.d;
std::swap(above, below);
}
const double facing = std::max(0.0, dot(p.n, prior));
const double on_top = (double)above / (double)(above + below + 1);
const double score = (double)p.inliers * (0.3 + 0.7 * on_top) *
(trusted ? 0.25 + 0.75 * facing : 0.6 + 0.4 * facing);
if ((trusted ? facing > 0.5 : on_top > 0.6) && score > best_score) {
best_score = score;
best = (int)i;
}
}
double R[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1};
double lift = 0.0;
const double zaxis[3] = {0, 0, 1};
if (best >= 0) {
const Plane& g = oriented[(size_t)best];
rotation_between(g.n, zaxis, R);
// After the turn the plane is z = -d.
lift = g.d;
res.ground = true;
res.ground_share = (double)g.inliers / (double)n;
} else {
rotation_between(prior, zaxis, R);
}
// The walls: whatever stands upright, folded by quarter turns so that the
// four faces of a room vote for the same heading.
if (opt.yaw) {
double sx = 0, sy = 0, total = 0, upright = 0;
auto vote = [&](const double nrm[3], double w) {
double v[3];
for (int r = 0; r < 3; r++)
v[r] = R[r*3]*nrm[0] + R[r*3+1]*nrm[1] + R[r*3+2]*nrm[2];
total += w;
if (std::fabs(v[2]) > 0.25) return;
const double a = 4.0 * std::atan2(v[1], v[0]);
sx += w * std::cos(a);
sy += w * std::sin(a);
upright += w;
};
if (normals && weights) {
const int64_t step = std::max<int64_t>(1, n / 400000);
for (int64_t i = 0; i < n; i += step) {
if (!(weights[i] > 0.0f)) continue;
const double v[3] = {normals[i*3], normals[i*3+1], normals[i*3+2]};
vote(v, weights[i]);
}
} else {
for (size_t i = 0; i < oriented.size(); i++)
if ((int)i != best) vote(oriented[i].n, (double)oriented[i].inliers);
total = (double)n;
}
const double agree = upright > 0 ? std::sqrt(sx*sx + sy*sy) / upright : 0.0;
if (agree > 0.35 && upright > 0.08 * total) {
const double heading = std::atan2(sy, sx) / 4.0; // (-45, 45] deg
const double c = std::cos(-heading), s = std::sin(-heading);
const double Z[9] = {c, -s, 0, s, c, 0, 0, 0, 1};
double RZ[9];
for (int r = 0; r < 3; r++)
for (int q = 0; q < 3; q++) {
RZ[r*3+q] = 0;
for (int k = 0; k < 3; k++) RZ[r*3+q] += Z[r*3+k] * R[k*3+q];
}
for (int i = 0; i < 9; i++) R[i] = RZ[i];
res.walls = true;
}
}
for (int i = 0; i < 9; i++) res.T.R[i] = R[i];
res.T.t[2] = lift;
if (opt.centre) {
// The median of the turned footprint: a floater does not drag it.
const int64_t step = std::max<int64_t>(1, n / 200000);
std::vector<double> xs, ys;
for (int64_t i = 0; i < n; i += step) {
double q[3];
res.T.apply(pts + i*3, q);
xs.push_back(q[0]);
ys.push_back(q[1]);
}
std::nth_element(xs.begin(), xs.begin() + xs.size() / 2, xs.end());
std::nth_element(ys.begin(), ys.begin() + ys.size() / 2, ys.end());
res.T.t[0] = -xs[xs.size() / 2];
res.T.t[1] = -ys[ys.size() / 2];
}
return res;
}
} // namespace align
} // namespace gui
+74
View File
@@ -0,0 +1,74 @@
#pragma once
// Finding the frame a scene WANTS: its ground, its walls, a corner of it.
//
// Everything here is geometry over a bare point array, in whatever frame the
// caller hands it, so it is testable without a window. Planes come from
// RANSAC and are then refit by least squares over their inliers; a fit that
// starts from a click GROWS outward while the surface keeps agreeing with it,
// because a floor measured over a metre levels a room better than one
// measured over the hand-width around the cursor.
#include "core/Similarity.h"
#include <cstdint>
#include <vector>
namespace gui {
namespace align {
// n . x + d = 0, n unit.
struct Plane {
double n[3] = {0, 0, 1};
double d = 0.0;
int64_t inliers = 0;
double distance(const double p[3]) const {
return n[0]*p[0] + n[1]*p[1] + n[2]*p[2] + d;
}
};
// The best-supported plane within `tol`. `inlier`, when given, comes back one
// flag per point. False when no three points agree on anything.
bool fit_plane(const double* pts, int64_t n, double tol, uint32_t seed,
Plane& out, std::vector<uint8_t>* inlier = nullptr);
// Up to `k` planes, largest first, each one's inliers removed before the next
// is looked for. A plane holding under `min_frac` of the points ends the list.
std::vector<Plane> find_planes(const double* pts, int64_t n, double tol, int k,
double min_frac);
// The surface under a click: fitted within `r0` of `at`, then refitted over
// twice the radius for as long as the wider patch still lies on it.
bool fit_plane_at(const double* pts, int64_t n, const double at[3], double r0,
Plane& out);
// Up to three mutually perpendicular surfaces meeting near `at`, made exactly
// orthogonal. `axes` rows are their normals; `corner` is where they meet (the
// click, projected, when fewer than three were found). Returns how many.
int fit_corner(const double* pts, int64_t n, const double at[3], double r0,
double axes[9], double corner[3]);
// The shortest rotation taking unit `a` onto unit `b`, row-major.
void rotation_between(const double a[3], const double b[3], double R[9]);
struct AutoAlignOptions {
double tol = 0.01; // plane thickness, in the points' own units
bool yaw = true; // turn the walls onto the axes
bool centre = true; // put the middle of the footprint at x=y=0
};
struct AutoAlignResult {
spirula::Sim3 T;
bool ground = false, walls = false;
double ground_share = 0.0; // of the points, within tol of the ground
};
// Ground to z = 0 with +Z up, walls onto the axes, footprint on the origin.
// `up` is a prior (null for +Z). `normals` / `weights` are optional, one per
// point: with them the walls come from the normals rather than from planes.
AutoAlignResult auto_align(const double* pts, int64_t n, const double* up,
const float* normals, const float* weights,
const AutoAlignOptions& opt);
} // namespace align
} // namespace gui
+329
View File
@@ -0,0 +1,329 @@
// Attributes.cpp -- see Attributes.h.
#include "app/gui/edit/Attributes.h"
#include "app/gui/edit/EditDoc.h"
#include "checkpoint/SplatPly.h"
#include "i18n/catalog/EditAttributes.h"
#include <algorithm>
#include <cmath>
#include <limits>
namespace msg = spirula::i18n::msg::attr;
namespace gui {
namespace {
constexpr float kNaN = std::numeric_limits<float>::quiet_NaN();
// The rasterizer's alpha cut (core/Common.cuh ALPHA_THRESHOLD), which is what
// decides how far from its centre a Gaussian is still drawn.
constexpr float kAlphaCut = 1.0f / 255.0f;
const AttrInfo kTable[(int)Attr::Count] = {
{Attr::PosX, &msg::a_pos_x, &msg::a_pos_help, false, AttrTint::Red},
{Attr::PosY, &msg::a_pos_y, &msg::a_pos_help, false, AttrTint::Green},
{Attr::PosZ, &msg::a_pos_z, &msg::a_pos_help, false, AttrTint::Blue},
{Attr::Opacity, &msg::a_opacity, &msg::a_opacity_help, false, AttrTint::None, 0.0, 1.0},
{Attr::ScaleMax, &msg::a_scale_max, &msg::a_scale_help, true, AttrTint::None},
{Attr::ScaleMin, &msg::a_scale_min, &msg::a_scale_help, true, AttrTint::None},
{Attr::ScaleMean, &msg::a_scale_mean, &msg::a_scale_help, true, AttrTint::None},
{Attr::ExtentMax, &msg::a_extent_max, &msg::a_extent_help, true, AttrTint::None},
{Attr::ExtentMin, &msg::a_extent_min, &msg::a_extent_help, true, AttrTint::None},
{Attr::ExtentMean, &msg::a_extent_mean, &msg::a_extent_help, true, AttrTint::None},
{Attr::AnisoRatio, &msg::a_aniso_ratio, &msg::a_aniso_ratio_help, true, AttrTint::None},
{Attr::Erank, &msg::a_erank, &msg::a_erank_help, false, AttrTint::None, 1.0, 3.0},
{Attr::Red, &msg::a_red, &msg::a_colour_help, false, AttrTint::Red},
{Attr::Green, &msg::a_green, &msg::a_colour_help, false, AttrTint::Green},
{Attr::Blue, &msg::a_blue, &msg::a_colour_help, false, AttrTint::Blue},
{Attr::Luma, &msg::a_luma, &msg::a_luma_help, false, AttrTint::Gray},
{Attr::ChromaU, &msg::a_chroma_u, &msg::a_chroma_help, false, AttrTint::None},
{Attr::ChromaV, &msg::a_chroma_v, &msg::a_chroma_help, false, AttrTint::None},
{Attr::Hue, &msg::a_hue, &msg::a_hue_help, false, AttrTint::Hue, 0.0, 360.0},
{Attr::Saturation, &msg::a_saturation, &msg::a_saturation_help, false, AttrTint::None},
{Attr::CameraDistance, &msg::a_camera_distance, &msg::a_camera_distance_help, true,
AttrTint::None},
};
bool is_colour(Attr a) { return a >= Attr::Red && a <= Attr::Saturation; }
bool is_shape(Attr a) { return a >= Attr::Opacity && a <= Attr::Erank; }
float colour_scalar(Attr a, const float* c) {
const float r = c[0], g = c[1], b = c[2];
// BT.709 luma and the colour differences that go with it.
const float y = 0.2126f * r + 0.7152f * g + 0.0722f * b;
switch (a) {
case Attr::Red: return r;
case Attr::Green: return g;
case Attr::Blue: return b;
case Attr::Luma: return y;
case Attr::ChromaU: return (b - y) / 1.8556f;
case Attr::ChromaV: return (r - y) / 1.5748f;
default: break;
}
const float mx = std::max(r, std::max(g, b)), mn = std::min(r, std::min(g, b));
const float d = mx - mn;
if (a == Attr::Saturation) return mx > 1e-6f ? d / mx : 0.0f;
// Hue in degrees. A grey has none, and saying 0 would file every grey
// under red.
if (d < 1e-4f * std::max(mx, 1e-3f)) return kNaN;
float h = mx == r ? (g - b) / d : mx == g ? 2.0f + (b - r) / d : 4.0f + (r - g) / d;
h *= 60.0f;
return h < 0 ? h + 360.0f : h;
}
void srgb_to_oklab(const float* c, float w_l, float out[3]) {
float lin[3];
for (int k = 0; k < 3; k++) {
const float a = std::fabs(c[k]);
const float v = a <= 0.04045f ? a / 12.92f : std::pow((a + 0.055f) / 1.055f, 2.4f);
lin[k] = c[k] < 0 ? -v : v;
}
const float l = std::cbrt(0.4122214708f*lin[0] + 0.5363325363f*lin[1] + 0.0514459929f*lin[2]);
const float m = std::cbrt(0.2119034982f*lin[0] + 0.6806995451f*lin[1] + 0.1073969566f*lin[2]);
const float s = std::cbrt(0.0883024619f*lin[0] + 0.2817188376f*lin[1] + 0.6299787005f*lin[2]);
out[0] = w_l * (0.2104542553f*l + 0.7936177850f*m - 0.0040720468f*s);
out[1] = 1.9779984951f*l - 2.4285922050f*m + 0.4505937099f*s;
out[2] = 0.0259040371f*l + 0.7827717662f*m - 0.8086757660f*s;
}
} // namespace
const AttrInfo& attr_info(Attr a) { return kTable[(int)a]; }
std::vector<Attr> attributes_of(const EditDoc& doc) {
std::vector<Attr> out;
const bool splats = doc.splats() != nullptr;
const bool colour = doc.colours_available();
if (splats)
for (Attr a : {Attr::Opacity, Attr::ExtentMax, Attr::ExtentMean,
Attr::ExtentMin, Attr::ScaleMax, Attr::ScaleMean,
Attr::ScaleMin, Attr::AnisoRatio, Attr::Erank})
out.push_back(a);
if (colour)
for (Attr a : {Attr::Luma, Attr::Hue, Attr::Saturation, Attr::Red,
Attr::Green, Attr::Blue, Attr::ChromaU, Attr::ChromaV})
out.push_back(a);
if (!splats && !doc.camera_centres().empty() && doc.layer() == 0)
out.push_back(Attr::CameraDistance);
for (Attr a : {Attr::PosZ, Attr::PosX, Attr::PosY}) out.push_back(a);
return out;
}
bool attribute_values(const EditDoc& doc, Attr a, std::vector<float>& out) {
const int64_t n = doc.count();
out.assign((size_t)n, kNaN);
if (a <= Attr::PosZ) {
// positions() frame -> the file's, with the placement on top.
const spirula::Sim3 to_saved = doc.view_frame().inverse() * doc.placement();
const float* p = doc.positions();
const int axis = (int)a;
#pragma omp parallel for schedule(static)
for (int64_t i = 0; i < n; i++) {
const double q[3] = {p[i*3], p[i*3+1], p[i*3+2]};
double w[3];
to_saved.apply(q, w);
out[(size_t)i] = (float)w[axis];
}
return true;
}
if (is_shape(a)) {
const spirula::SplatCloud* c = doc.splats();
if (!c || c->num != n) return false;
const float grow = (float)std::log(doc.file_placement().s);
#pragma omp parallel for schedule(static)
for (int64_t i = 0; i < n; i++) {
const float* s = &c->scales[(size_t)i * 3];
const float hi = std::max(s[0], std::max(s[1], s[2])) + grow;
const float lo = std::min(s[0], std::min(s[1], s[2])) + grow;
const float mean = (s[0] + s[1] + s[2]) / 3.0f + grow;
const float op = 1.0f / (1.0f + std::exp(-c->opacities[(size_t)i]));
// Zero for a Gaussian the rasterizer never draws at all.
const float reach = op > kAlphaCut
? std::sqrt(2.0f * std::log(op / kAlphaCut)) : 0.0f;
float v = kNaN;
switch (a) {
case Attr::Opacity: v = op; break;
case Attr::ScaleMax: v = std::exp(hi); break;
case Attr::ScaleMin: v = std::exp(lo); break;
case Attr::ScaleMean: v = std::exp(mean); break;
case Attr::ExtentMax: v = std::exp(hi) * reach; break;
case Attr::ExtentMin: v = std::exp(lo) * reach; break;
case Attr::ExtentMean: v = std::exp(mean) * reach; break;
case Attr::AnisoRatio: v = std::exp(std::min(hi - lo, 60.0f)); break;
default: {
// The effective rank of the covariance, as the erank
// regularizer has it (shaders/per_splat_losses.slang).
double e[3], sum = 0.0;
for (int k = 0; k < 3; k++) {
e[k] = std::exp(2.0 * (double)(s[k] + grow - hi));
sum += e[k];
}
double h = 0.0;
for (int k = 0; k < 3; k++) {
const double q = std::max(e[k] / sum, 1e-30);
h -= q * std::log(q);
}
v = (float)std::exp(h);
}
}
out[(size_t)i] = v;
}
return true;
}
if (is_colour(a)) {
std::vector<float> rgb;
if (!doc.colours(rgb) || (int64_t)rgb.size() != n * 3) return false;
#pragma omp parallel for schedule(static)
for (int64_t i = 0; i < n; i++)
out[(size_t)i] = colour_scalar(a, &rgb[(size_t)i * 3]);
return true;
}
if (a == Attr::CameraDistance) {
const std::vector<float> cams = doc.camera_centres();
const int64_t nc = (int64_t)cams.size() / 3;
if (nc == 0) return false;
const float* p = doc.positions();
const float unit = (float)(doc.placement().s / doc.view_frame().s);
#pragma omp parallel for schedule(static)
for (int64_t i = 0; i < n; i++) {
float best = std::numeric_limits<float>::max();
for (int64_t c = 0; c < nc; c++) {
const float dx = p[i*3] - cams[(size_t)c*3];
const float dy = p[i*3+1] - cams[(size_t)c*3+1];
const float dz = p[i*3+2] - cams[(size_t)c*3+2];
best = std::min(best, dx*dx + dy*dy + dz*dz);
}
out[(size_t)i] = std::sqrt(best) * unit;
}
return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Histogram
// ---------------------------------------------------------------------------
void AttrHistogram::build(const std::vector<float>& v, const uint8_t* alive,
const uint8_t* sel, bool log_axis, double fixed_lo,
double fixed_hi) {
log = log_axis;
all.assign(kBins, 0);
selected.assign(kBins, 0);
peak = 0;
live = 0;
const int64_t n = (int64_t)v.size();
auto axis = [&](float x) -> double {
if (!log) return (double)x;
return x > 0.0f ? std::log10((double)x) : -std::numeric_limits<double>::infinity();
};
// The range, from a sample: a percentile does not get better for being
// taken over more of the same distribution.
const int64_t step = std::max<int64_t>(1, n / 200000);
std::vector<double> sample;
for (int64_t i = 0; i < n; i += step) {
if (alive && !alive[i]) continue;
const double a = axis(v[(size_t)i]);
if (std::isfinite(a)) sample.push_back(a);
}
if (sample.empty()) { lo = 0.0; hi = 1.0; return; }
auto pct = [&](double q) {
const size_t k = (size_t)std::clamp(q * (double)(sample.size() - 1), 0.0,
(double)(sample.size() - 1));
std::nth_element(sample.begin(), sample.begin() + (ptrdiff_t)k, sample.end());
return sample[k];
};
lo = pct(0.002);
hi = pct(0.998);
if (!(hi > lo)) {
const double pad = std::max(std::fabs(lo) * 1e-3, 1e-6);
lo -= pad;
hi += pad;
}
const double pad = (hi - lo) * 0.02;
lo -= pad;
hi += pad;
if (fixed_hi > fixed_lo) {
lo = fixed_lo;
hi = fixed_hi;
}
const double k = kBins / (hi - lo);
for (int64_t i = 0; i < n; i++) {
if (alive && !alive[i]) continue;
const double a = axis(v[(size_t)i]);
if (std::isnan(a)) continue;
const int b = (int)std::clamp((a - lo) * k, 0.0, (double)(kBins - 1));
all[(size_t)b]++;
if (sel && sel[i]) selected[(size_t)b]++;
live++;
}
for (uint32_t c : all) peak = std::max(peak, c);
}
double AttrHistogram::value_at(double frac) const {
const double a = lo + (hi - lo) * frac;
return log ? std::pow(10.0, a) : a;
}
double AttrHistogram::frac_of(double value) const {
const double a = log ? (value > 0 ? std::log10(value) : lo) : value;
return (a - lo) / (hi - lo);
}
void select_by_range(const std::vector<float>& v, const AttrHistogram& h,
double f0, double f1, bool outside, const uint8_t* alive,
std::vector<uint8_t>& out) {
const int64_t n = (int64_t)v.size();
out.assign((size_t)n, 0);
if (f0 > f1) std::swap(f0, f1);
const double inf = std::numeric_limits<double>::infinity();
// An end dragged to the edge of the plot means "and everything past it".
const double a0 = f0 <= 0.0 ? -inf : h.lo + (h.hi - h.lo) * f0;
const double a1 = f1 >= 1.0 ? inf : h.lo + (h.hi - h.lo) * f1;
const bool log = h.log;
#pragma omp parallel for schedule(static)
for (int64_t i = 0; i < n; i++) {
if (alive && !alive[i]) continue;
const float x = v[(size_t)i];
if (std::isnan(x)) continue;
const double a = log ? (x > 0.0f ? std::log10((double)x) : -inf) : (double)x;
if ((a >= a0 && a <= a1) != outside) out[(size_t)i] = 255;
}
}
void select_by_colour(const std::vector<float>& rgb, const float* samples,
int k, float tolerance, float lightness_weight,
const uint8_t* alive, std::vector<uint8_t>& out) {
const int64_t n = (int64_t)rgb.size() / 3;
out.assign((size_t)n, 0);
if (k <= 0) return;
std::vector<float> lab((size_t)k * 3);
for (int j = 0; j < k; j++) srgb_to_oklab(samples + j * 3, lightness_weight, &lab[(size_t)j * 3]);
const float t2 = tolerance * tolerance;
#pragma omp parallel for schedule(static)
for (int64_t i = 0; i < n; i++) {
if (alive && !alive[i]) continue;
float c[3];
srgb_to_oklab(&rgb[(size_t)i * 3], lightness_weight, c);
for (int j = 0; j < k; j++) {
const float d0 = c[0] - lab[(size_t)j*3], d1 = c[1] - lab[(size_t)j*3+1],
d2 = c[2] - lab[(size_t)j*3+2];
if (d0*d0 + d1*d1 + d2*d2 <= t2) {
out[(size_t)i] = 255;
break;
}
}
}
}
} // namespace gui
+90
View File
@@ -0,0 +1,90 @@
#pragma once
// Per-element scalars a selection can be made from, and the histogram they
// are brushed on. One table: a name, where the numbers come from and how they
// want to be looked at, so the panel is generated from it and the twentieth
// attribute is a row rather than a feature.
//
// A document offers raw material -- a colour per element, the Gaussians
// themselves, its cameras -- and everything derived from that lives here.
#include "core/Similarity.h"
#include <cstdint>
#include <vector>
namespace spirula { namespace i18n { struct Msg; } }
namespace gui {
class EditDoc;
enum class Attr : int {
// Where it is, in the coordinates it will be SAVED in.
PosX = 0, PosY, PosZ,
// A Gaussian's shape. "Extent" is the scale times sqrt(2 ln(opacity *
// 255)): how far out it still reaches the rasterizer's alpha cut.
Opacity, ScaleMax, ScaleMin, ScaleMean, ExtentMax, ExtentMin, ExtentMean,
AnisoRatio, Erank,
// Its base colour, display-referred and unclamped.
Red, Green, Blue, Luma, ChromaU, ChromaV, Hue, Saturation,
// A sparse point's distance to the nearest camera.
CameraDistance,
Count
};
// How the histogram bars are coloured, where that says something.
enum class AttrTint { None, Red, Green, Blue, Gray, Hue };
struct AttrInfo {
Attr id;
const spirula::i18n::Msg* name;
const spirula::i18n::Msg* help;
bool log; // spans decades: bin its log10
AttrTint tint;
// A range that is the attribute's own rather than the data's: a hue is
// 0..360 whatever the model holds. lo == hi leaves it to the data.
double lo = 0.0, hi = 0.0;
};
const AttrInfo& attr_info(Attr a);
// What the document's CURRENT layer can be asked for, in panel order.
std::vector<Attr> attributes_of(const EditDoc& doc);
// One value per element; NaN where the element has none. False when the
// layer does not carry what `a` needs.
bool attribute_values(const EditDoc& doc, Attr a, std::vector<float>& out);
// 256 bins over a robust range of the LIVE values -- the 0.2th to the 99.8th
// percentile, so the three floaters a kilometre out do not squash everything
// else into one bin. The two end bins also hold what lies beyond them.
struct AttrHistogram {
static constexpr int kBins = 256;
bool log = false;
double lo = 0.0, hi = 1.0; // bin-axis range (log10 when `log`)
std::vector<uint32_t> all, selected;
uint32_t peak = 0;
int64_t live = 0;
void build(const std::vector<float>& v, const uint8_t* alive,
const uint8_t* sel, bool log_axis, double fixed_lo = 0.0,
double fixed_hi = 0.0);
// The attribute value at a fraction 0..1 across the axis, and back.
double value_at(double frac) const;
double frac_of(double value) const;
};
// Live elements within the fractions [f0, f1] of `h`'s axis. An end at 0 or
// 1 is open: that is where the out-of-range values were binned. `outside` is
// the complement, which is also how a hue range runs through red.
void select_by_range(const std::vector<float>& v, const AttrHistogram& h,
double f0, double f1, bool outside, const uint8_t* alive,
std::vector<uint8_t>& out);
// Within `tolerance` of ANY of the `k` samples, in OKLab -- where equal
// distances look equally different. Both arrays are [., 3] display-referred;
// `lightness_weight` 0 ignores how bright a colour is.
void select_by_colour(const std::vector<float>& rgb, const float* samples,
int k, float tolerance, float lightness_weight,
const uint8_t* alive, std::vector<uint8_t>& out);
} // namespace gui
+398
View File
@@ -0,0 +1,398 @@
// EditAttributes.cpp -- the editing session's "select by what it is" half:
// the brushable histogram over app/gui/edit/Attributes.h and the colour
// sampler. The session is in EditSession.h.
#include "app/gui/edit/EditSession.h"
#include "app/gui/Layout.h"
#include "app/gui/Ui.h"
#include "i18n/catalog/Edit.h"
#include "i18n/catalog/EditAttributes.h"
#include "imgui.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
namespace msg = spirula::i18n::msg::attr;
namespace emsg = spirula::i18n::msg::edit;
using spirula::i18n::Msg;
namespace gui {
namespace {
// Live preview while a range or a tolerance is being dragged: often enough to
// steer by, seldom enough that a colour upload per frame is not the cost.
constexpr double kPreviewEvery = 0.07;
constexpr int kAdjustRange = 1, kAdjustColour = 2;
ImU32 bar_colour(AttrTint tint, float frac) {
switch (tint) {
case AttrTint::Red: return IM_COL32(200, 90, 100, 255);
case AttrTint::Green: return IM_COL32(110, 180, 80, 255);
case AttrTint::Blue: return IM_COL32(80, 140, 220, 255);
case AttrTint::Gray: {
const int v = 60 + (int)(frac * 180.0f);
return IM_COL32(v, v, v, 255);
}
case AttrTint::Hue: {
float r, g, b;
ImGui::ColorConvertHSVtoRGB(frac, 0.75f, 0.85f, r, g, b);
return IM_COL32((int)(r * 255), (int)(g * 255), (int)(b * 255), 255);
}
default: return IM_COL32(130, 140, 155, 255);
}
}
std::string number(double v) {
char buf[32];
std::snprintf(buf, sizeof buf, "%.4g", v);
return buf;
}
} // namespace
// ---------------------------------------------------------------------------
// A selection that is adjusted rather than stacked
// ---------------------------------------------------------------------------
void EditSession::begin_adjustable(int kind) {
if (!_doc || _adjust_live) return;
// Dragging the same control again straight after: the step it made is
// taken back, so the new one replaces it instead of meeting its result.
if (_adjust_kind == kind && _adjust_head >= 0 &&
_adjust_head == _doc->history_head() && _doc->can_undo())
_doc->undo();
_adjust_kind = kind;
_adjust_before = _doc->sel().weights();
_adjust_live = true;
_preview_at = 0.0;
}
void EditSession::preview_adjustable(const std::vector<uint8_t>& w) {
if (!_doc || !_adjust_live) return;
const double now = ImGui::GetTime();
if (now - _preview_at < kPreviewEvery) return;
_preview_at = now;
Selection tmp;
tmp.assign(_adjust_before);
const ImGuiIO& io = ImGui::GetIO();
tmp.combine(w.data(), combine_now(io.KeyShift, io.KeyCtrl), _doc->alive());
_doc->set_selection(tmp.weights());
_doc->mark_display_dirty();
}
void EditSession::commit_adjustable(const std::vector<uint8_t>& w,
const std::string& label) {
if (!_doc || !_adjust_live) return;
_adjust_live = false;
Selection tmp;
tmp.assign(_adjust_before);
const ImGuiIO& io = ImGui::GetIO();
tmp.combine(w.data(), combine_now(io.KeyShift, io.KeyCtrl), _doc->alive());
// The preview wrote the selection without a step; the step has to start
// from what was there before it.
_doc->set_selection(_adjust_before);
_doc->run(make_select_op(*_doc, tmp.weights(), label, {}));
_adjust_head = _doc->history_head();
}
// ---------------------------------------------------------------------------
// By attribute
// ---------------------------------------------------------------------------
void EditSession::refresh_attribute() {
if (!_doc) return;
if (_attrs_layer != _doc->layer() || _attrs.empty()) {
_attrs = attributes_of(*_doc);
_attrs_layer = _doc->layer();
_attr = 0;
_hist_attr = -1;
_range_set = false;
}
if (_attrs.empty()) return;
_attr = std::clamp(_attr, 0, (int)_attrs.size() - 1);
const bool new_attr = _hist_attr != (int)_attrs[(size_t)_attr];
if (!new_attr && _hist_rev == _doc->revision()) return;
const Attr a = _attrs[(size_t)_attr];
// The nearest-camera distance is the one attribute that costs more than
// a pass over the elements, and the only thing that moves it is a camera.
const bool keep_values = !new_attr && a == Attr::CameraDistance &&
_attr_values.size() == (size_t)_doc->count();
if (!keep_values && !attribute_values(*_doc, a, _attr_values)) {
_attr_values.clear();
return;
}
const AttrInfo& info = attr_info(a);
_hist.build(_attr_values, _doc->alive(), _doc->sel().data(), info.log,
info.lo, info.hi);
_hist_attr = (int)a;
_hist_rev = _doc->revision();
if (new_attr) _range_set = false;
}
void EditSession::draw_attribute_section(float full) {
refresh_attribute();
if (_attrs.empty() || _attr_values.empty()) {
ui::TextDisabledWrapped(msg::none_here);
return;
}
const ImGuiStyle& st = ImGui::GetStyle();
const AttrInfo& info = attr_info(_attrs[(size_t)_attr]);
ImGui::SetNextItemWidth(full);
if (ui::BeginComboRaw("##attr", info.name->get())) {
for (int i = 0; i < (int)_attrs.size(); i++)
if (ui::Selectable(*attr_info(_attrs[(size_t)i]).name, i == _attr))
_attr = i;
ImGui::EndCombo();
}
ui::help_on_hover(*info.help);
// ---- the plot ----
const float h = px(96.0f);
const ImVec2 p0 = ImGui::GetCursorScreenPos();
ui::InvisibleButtonRaw("##histplot", ImVec2(full, h));
const bool hovered = ImGui::IsItemHovered();
const bool pressed = ImGui::IsItemActivated();
const bool held = ImGui::IsItemActive();
const bool let_go = ImGui::IsItemDeactivated();
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImVec2 p1(p0.x + full, p0.y + h);
dl->AddRectFilled(p0, p1, IM_COL32(18, 20, 24, 255), px(3.0f));
const float peak = _hist_log_counts ? std::log1p((float)_hist.peak)
: (float)_hist.peak;
if (peak > 0.0f) {
const int cols = std::max(1, (int)full);
for (int x = 0; x < cols; x++) {
// A column can cover several bins or a bin several columns; the
// tallest bin under it is what a thin spike needs to stay visible.
const int b0 = x * AttrHistogram::kBins / cols;
const int b1 = std::max(b0 + 1, (x + 1) * AttrHistogram::kBins / cols);
uint32_t all = 0, sel = 0;
for (int b = b0; b < b1 && b < AttrHistogram::kBins; b++) {
all = std::max(all, _hist.all[(size_t)b]);
sel = std::max(sel, _hist.selected[(size_t)b]);
}
if (!all) continue;
auto height = [&](uint32_t c) {
const float v = _hist_log_counts ? std::log1p((float)c) : (float)c;
return std::max(1.0f, v / peak * (h - px(4.0f)));
};
const float fx = (float)x / (float)cols;
dl->AddRectFilled(ImVec2(p0.x + x, p1.y - height(all)),
ImVec2(p0.x + x + 1, p1.y), bar_colour(info.tint, fx));
if (sel)
dl->AddRectFilled(ImVec2(p0.x + x, p1.y - height(sel)),
ImVec2(p0.x + x + 1, p1.y), IM_COL32(255, 120, 20, 255));
}
}
// ---- the range ----
const float mx = (ImGui::GetIO().MousePos.x - p0.x) / std::max(full, 1.0f);
const double mf = std::clamp((double)mx, 0.0, 1.0);
const float grab = px(6.0f) / std::max(full, 1.0f);
if (pressed) {
begin_adjustable(kAdjustRange);
if (_range_set && std::fabs(mf - _range[0]) < grab) _range_drag = 1;
else if (_range_set && std::fabs(mf - _range[1]) < grab) _range_drag = 2;
else {
_range_drag = 3;
_range_anchor = mf;
_range[0] = _range[1] = mf;
_range_set = true;
}
}
auto tool_answer = [&](std::vector<uint8_t>& w) {
select_by_range(_attr_values, _hist, _range[0], _range[1], _range_outside,
_doc->alive(), w);
};
auto label = [&] {
return spirula::i18n::format(
msg::op_select_by,
{info.name->get(), number(_hist.value_at(std::min(_range[0], _range[1]))),
number(_hist.value_at(std::max(_range[0], _range[1])))});
};
if (held && _range_drag) {
if (_range_drag == 1) _range[0] = mf;
else if (_range_drag == 2) _range[1] = mf;
else {
_range[0] = std::min(_range_anchor, mf);
_range[1] = std::max(_range_anchor, mf);
}
if (_range[0] > _range[1]) {
std::swap(_range[0], _range[1]);
if (_range_drag != 3) _range_drag = 3 - _range_drag;
}
std::vector<uint8_t> w;
tool_answer(w);
preview_adjustable(w);
}
if (let_go && _range_drag) {
_range_drag = 0;
std::vector<uint8_t> w;
tool_answer(w);
commit_adjustable(w, label());
}
if (_range_set) {
const float x0 = p0.x + (float)_range[0] * full, x1 = p0.x + (float)_range[1] * full;
const ImU32 wash = IM_COL32(255, 255, 255, 34);
if (_range_outside) {
dl->AddRectFilled(p0, ImVec2(x0, p1.y), wash);
dl->AddRectFilled(ImVec2(x1, p0.y), p1, wash);
} else {
dl->AddRectFilled(ImVec2(x0, p0.y), ImVec2(x1, p1.y), wash);
}
for (float x : {x0, x1}) {
dl->AddLine(ImVec2(x, p0.y), ImVec2(x, p1.y), IM_COL32(255, 255, 255, 220), px(1.5f));
dl->AddRectFilled(ImVec2(x - px(3.0f), p0.y + h * 0.4f),
ImVec2(x + px(3.0f), p0.y + h * 0.6f),
IM_COL32(255, 255, 255, 230), px(2.0f));
}
}
if (hovered && !held) {
dl->AddLine(ImVec2(p0.x + (float)mf * full, p0.y),
ImVec2(p0.x + (float)mf * full, p1.y), IM_COL32(255, 255, 255, 70));
ui::SetTooltipRaw(number(_hist.value_at(mf)));
}
// The ends of the axis, and what the range is in the attribute's units.
ui::TextDisabledRaw(number(_hist.value_at(0.0)));
const std::string top = number(_hist.value_at(1.0));
ImGui::SameLine(full - ImGui::CalcTextSize(top.c_str()).x);
ui::TextDisabledRaw(top);
if (_range_set) {
// Typed ends, for a threshold somebody already knows.
const float fw = (full - st.ItemSpacing.x) * 0.5f;
float lo = (float)_hist.value_at(_range[0]), hi = (float)_hist.value_at(_range[1]);
bool changed = false;
ImGui::SetNextItemWidth(fw);
ui::InputFloatRaw("##rangelo", &lo, "%.4g");
changed |= ImGui::IsItemDeactivatedAfterEdit();
ImGui::SameLine();
ImGui::SetNextItemWidth(fw);
ui::InputFloatRaw("##rangehi", &hi, "%.4g");
changed |= ImGui::IsItemDeactivatedAfterEdit();
if (changed) {
_range[0] = std::clamp(_hist.frac_of(std::min(lo, hi)), 0.0, 1.0);
_range[1] = std::clamp(_hist.frac_of(std::max(lo, hi)), 0.0, 1.0);
begin_adjustable(kAdjustRange);
std::vector<uint8_t> w;
tool_answer(w);
commit_adjustable(w, label());
}
} else {
ui::TextDisabledWrapped(msg::range_hint);
}
if (ui::Checkbox(msg::range_outside, &_range_outside) && _range_set) {
begin_adjustable(kAdjustRange);
std::vector<uint8_t> w;
tool_answer(w);
commit_adjustable(w, label());
}
ui::help_on_hover(msg::range_outside_help);
ImGui::SameLine();
ui::Checkbox(msg::log_counts, &_hist_log_counts);
ui::help_on_hover(msg::log_counts_help);
}
// ---------------------------------------------------------------------------
// By colour
// ---------------------------------------------------------------------------
void EditSession::run_colour(bool commit) {
if (!_doc || _samples.empty()) return;
const uint64_t key = ((uint64_t)_doc->layer() << 48) ^ (uint64_t)_doc->count();
if (_colours_key != key || _colours.size() != (size_t)_doc->count() * 3) {
if (!_doc->colours(_colours)) {
_colours.clear();
return;
}
_colours_key = key;
}
std::vector<uint8_t> w;
select_by_colour(_colours, _samples.data(), (int)_samples.size() / 3,
_colour_tol, _colour_light, _doc->alive(), w);
begin_adjustable(kAdjustColour);
if (commit) commit_adjustable(w, msg::op_select_colour.get());
else preview_adjustable(w);
}
void EditSession::pick_colour(float px_, float py_, bool append) {
ViewProjection vp;
if (!_doc || !view(vp)) return;
const int64_t hit = pick_element(*_doc, vp, px_, py_, 16.0f);
if (hit < 0) return;
std::vector<float> rgb;
if (!_doc->colours(rgb) || (int64_t)rgb.size() < (hit + 1) * 3) return;
// Shift adds a sample: a sky is a gradient, and one click is one blue.
if (!append) {
_samples.clear();
// A fresh colour is a new selection, not a correction of the last.
_adjust_head = -1;
}
if (_samples.size() >= 8 * 3) _samples.erase(_samples.begin(), _samples.begin() + 3);
_samples.insert(_samples.end(), rgb.begin() + hit * 3, rgb.begin() + hit * 3 + 3);
_adjust_live = false;
run_colour(/*commit=*/true);
}
void EditSession::draw_colour_section(float full) {
if (!_doc->colours_available()) {
ui::TextDisabledWrapped(msg::no_colour_here);
return;
}
const ImGuiStyle& st = ImGui::GetStyle();
ui::TextDisabledWrapped(msg::colour_hint);
bool edited = false;
for (int i = 0; i < (int)_samples.size() / 3; i++) {
if (i) ImGui::SameLine();
ImGui::PushID(i);
edited |= ui::ColorEdit3Raw("##sample", &_samples[(size_t)i * 3],
ImGuiColorEditFlags_NoInputs |
ImGuiColorEditFlags_NoLabel |
ImGuiColorEditFlags_Float |
ImGuiColorEditFlags_HDR);
if (ImGui::IsItemClicked(ImGuiMouseButton_Right)) {
_samples.erase(_samples.begin() + i * 3, _samples.begin() + i * 3 + 3);
edited = true;
ImGui::PopID();
break;
}
ImGui::PopID();
}
if (!_samples.empty()) ImGui::SameLine();
if (ui::Button(msg::colour_add)) {
_samples.insert(_samples.end(), {0.5f, 0.5f, 0.5f});
edited = true;
}
ui::help_on_hover(msg::colour_add_help);
float label_w = std::max(ImGui::CalcTextSize(msg::colour_tolerance.get()).x,
ImGui::CalcTextSize(msg::colour_lightness.get()).x);
const float sw = std::max(full - label_w - st.ItemInnerSpacing.x, full * 0.3f);
bool live = false, done = false;
ImGui::SetNextItemWidth(sw);
live |= ui::SliderFloat(msg::colour_tolerance, &_colour_tol, 0.005f, 0.5f, "%.3f");
done |= ImGui::IsItemDeactivatedAfterEdit();
ui::help_on_hover(msg::colour_tolerance_help);
ImGui::SetNextItemWidth(sw);
live |= ui::SliderFloat(msg::colour_lightness, &_colour_light, 0.0f, 1.0f, "%.2f");
done |= ImGui::IsItemDeactivatedAfterEdit();
ui::help_on_hover(msg::colour_lightness_help);
if (_samples.empty()) return;
if (done || edited) run_colour(true);
else if (live) run_colour(false);
}
} // namespace gui
+27
View File
@@ -338,6 +338,33 @@ private:
} // namespace
namespace {
class PlacementOp : public EditOp {
public:
PlacementOp(const spirula::Sim3& was, const spirula::Sim3& next,
std::string label, bool carried)
: _was(was), _next(next), _label(std::move(label)), _carried(carried) {}
bool carries_view() const override { return _carried; }
void apply(EditDoc& doc) override { doc.set_placement(_next); }
void undo(EditDoc& doc) override { doc.set_placement(_was); }
std::string label() const override { return _label; }
size_t bytes() const override { return sizeof *this + _label.size(); }
private:
spirula::Sim3 _was, _next;
std::string _label;
bool _carried;
};
} // namespace
std::unique_ptr<EditOp> make_placement_op(EditDoc& doc, const spirula::Sim3& next,
std::string label, bool carries_view) {
return std::make_unique<PlacementOp>(doc.placement(), next, std::move(label),
carries_view);
}
std::unique_ptr<EditOp> make_setting_op(std::function<void(bool)> apply,
std::string label,
std::shared_ptr<const SelectRecipe> recipe) {
+64 -2
View File
@@ -12,6 +12,7 @@
// and its cameras -- and every tool works on the one that is current.
#include "app/gui/edit/Selection.h"
#include "core/Similarity.h"
#include "data/SceneCenter.h"
#include "i18n/Message.h"
@@ -22,6 +23,8 @@
#include <string>
#include <vector>
namespace spirula { struct SplatCloud; }
namespace gui {
class EditDoc;
@@ -43,6 +46,9 @@ struct EditOp {
virtual size_t bytes() const = 0;
// Set on the steps that are a selection, null on the rest.
virtual std::shared_ptr<const SelectRecipe> recipe() const { return {}; }
// A placement made with the view taken along (an alignment): walking the
// history across it has to take the view back the same way.
virtual bool carries_view() const { return false; }
};
// What "Save a copy" can write this document as. `ext` is the extension a
@@ -115,8 +121,53 @@ public:
return at(layer).alive;
}
const Selection& sel_of(int layer) const { return at(layer).sel; }
const float* positions_of(int layer) const { return at(layer).pos.data(); }
int64_t alive_count_of(int layer) const { return at(layer).alive_count; }
// ---- placement ----
// One similarity of the positions() frame for every layer: the scene is
// rigid. The VIEWER applies it; nothing here moves until a save bakes it.
const spirula::Sim3& placement() const { return _placement; }
void set_placement(const spirula::Sim3& p) { _placement = p; _rev++; }
// File coordinates into the frame positions() are in.
virtual spirula::Sim3 view_frame() const = 0;
// The placement as the file's own coordinates see it: what a save writes.
spirula::Sim3 file_placement() const {
const spirula::Sim3 n = view_frame();
return n.inverse() * _placement * n;
}
// A unit normal and a weight per element of layer 0, in the positions()
// frame, where the document has them: a flat Gaussian, a mesh vertex.
virtual bool normals(std::vector<float>& n, std::vector<float>& w) const {
(void)n; (void)w;
return false;
}
// ---- raw material for the attribute table (Attributes.h) ----
// A display-referred, UNCLAMPED colour per element of the current layer.
virtual bool colours(std::vector<float>& rgb) const {
(void)rgb;
return false;
}
// The same question without the answer, for a panel deciding what to show.
virtual bool colours_available() const { return false; }
// The Gaussians themselves, when that is what the elements are.
virtual const spirula::SplatCloud* splats() const { return nullptr; }
// Camera centres in the positions() frame, [n, 3]; empty without cameras.
virtual std::vector<float> camera_centres() const { return {}; }
// How much each element of layer 0 is part of a SURFACE, 0..1, or null
// when they all are. A trained model is full of faint, oversized haze
// that no floor should be fitted through.
virtual const float* solidity() const { return nullptr; }
// Which way the people who took the photos thought was up, same frame:
// the mean of the cameras' own up axes. False without cameras.
virtual bool up_hint(float up[3]) const {
(void)up;
return false;
}
// ---- history ----
// Runs `op` and puts it on the stack; drops the oldest entries when the
// history is over its byte or count budget.
@@ -140,8 +191,11 @@ public:
// ---- what the ops write through ----
void set_alive(int64_t i, bool a);
void set_selection(const std::vector<uint8_t>& w);
void mark_geometry_dirty() { _geom_dirty = true; _display_dirty = true; }
void mark_display_dirty() { _display_dirty = true; }
void mark_geometry_dirty() { _geom_dirty = true; _display_dirty = true; _rev++; }
void mark_display_dirty() { _display_dirty = true; _rev++; }
// Bumped by every change to what is live or selected: what a cache of
// anything derived from either is keyed on.
uint64_t revision() const { return _rev; }
bool dirty() const { return _edited; }
void mark_saved() { _edited = false; }
@@ -202,6 +256,7 @@ private:
std::vector<Layer> _layers;
int _cur = 0;
std::string _source;
spirula::Sim3 _placement;
std::vector<std::unique_ptr<EditOp>> _ops;
int _head = 0;
@@ -209,6 +264,7 @@ private:
bool _edited = false;
bool _geom_dirty = true;
bool _display_dirty = true;
uint64_t _rev = 1;
};
@@ -235,4 +291,10 @@ std::unique_ptr<EditOp> make_setting_op(std::function<void(bool)> apply,
std::string label,
std::shared_ptr<const SelectRecipe> recipe);
// A placement change. Both ends are stored rather than the step between
// them, so walking the history back and forth never accumulates rounding.
std::unique_ptr<EditOp> make_placement_op(EditDoc& doc, const spirula::Sim3& next,
std::string label,
bool carries_view = false);
} // namespace gui
+394 -125
View File
@@ -5,12 +5,18 @@
#include "app/gui/Ui.h"
#include "i18n/catalog/Edit.h"
#include "i18n/catalog/EditAttributes.h"
#include "i18n/catalog/EditTransform.h"
#include "imgui.h"
#include <algorithm>
#include <cmath>
#include <string>
namespace msg = spirula::i18n::msg::edit;
namespace xmsg = spirula::i18n::msg::xform;
namespace amsg = spirula::i18n::msg::attr;
using spirula::i18n::Msg;
namespace gui {
@@ -156,6 +162,41 @@ void option_slider_int(EditSession& s, const Msg& m, int* slot, int lo, int hi,
}
}
constexpr double kPi = 3.14159265358979323846;
// R = Rz Ry Rx in degrees -- the order the comparison view's placement
// fields use, so the two sets of numbers mean the same thing.
void euler_of(const double R[9], float deg[3]) {
const double sy = -R[6];
double x, y, z;
if (std::fabs(sy) < 0.999999) {
y = std::asin(sy);
x = std::atan2(R[7], R[8]);
z = std::atan2(R[3], R[0]);
} else {
y = sy > 0 ? kPi / 2 : -kPi / 2;
x = std::atan2(-R[5], R[4]);
z = 0.0;
}
deg[0] = (float)(x * 180.0 / kPi);
deg[1] = (float)(y * 180.0 / kPi);
deg[2] = (float)(z * 180.0 / kPi);
// "-0.00" is what rounding leaves of an angle nobody asked for.
for (int k = 0; k < 3; k++)
if (std::fabs(deg[k]) < 5e-4f) deg[k] = 0.0f;
}
void euler_to(const float deg[3], double R[9]) {
const double k = kPi / 180.0;
const double cx = std::cos(deg[0]*k), sx = std::sin(deg[0]*k);
const double cy = std::cos(deg[1]*k), sy = std::sin(deg[1]*k);
const double cz = std::cos(deg[2]*k), sz = std::sin(deg[2]*k);
const double M[9] = {cz*cy, cz*sy*sx - sz*cx, cz*sy*cx + sz*sx,
sz*cy, sz*sy*sx + cz*cx, sz*sy*cx - cz*sx,
-sy, cy*sx, cy*cx};
for (int i = 0; i < 9; i++) R[i] = M[i];
}
} // namespace
@@ -163,24 +204,50 @@ void EditSession::handle_keys() {
ImGuiIO& io = ImGui::GetIO();
if (io.WantTextInput || ImGui::IsAnyItemActive()) return;
if (!_doc) return;
// A running operator reads the keyboard itself: X is an axis there, not
// "delete", and a digit is a distance.
if (_xform.active()) return;
// While Navigate is the active tool the camera owns WASDQE, so the keys
// that collide with it are not read here. Every other key still is, which
// is how a letter switches away from Navigate in the first place.
const bool fly = _tool.id() == ToolId::Navigate;
const bool plain = !io.KeyCtrl && !io.KeyAlt && !io.KeyShift;
for (int i = 0; i < kNumTools; i++) {
const ToolRow& row = tool_table()[i];
if (row.fly_key && fly) continue;
if (!io.KeyCtrl && !io.KeyAlt && !io.KeyShift &&
ImGui::IsKeyPressed((ImGuiKey)row.imgui_key, false)) {
_tool.set_id(row.id);
if (plain && ImGui::IsKeyPressed((ImGuiKey)row.imgui_key, false)) {
if (row.id == ToolId::Transform) {
enter_transform();
} else {
_tool.set_id(row.id);
_pick = Pick::None;
if (_tab != 0) { _tab = 0; _tab_force = true; }
}
// The key is still down this frame; the camera must not also read
// it on the way into Navigate.
if (row.fly_key) _fly_block_key = row.imgui_key;
}
}
if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) _tool.cancel();
// G / R / S, from any tool -- except S under Navigate, where it is the
// camera's "back" and has been since before there was an editor.
if (plain) {
const struct { ImGuiKey key; XformKind kind; bool fly; } ops[] = {
{ImGuiKey_G, XformKind::Move, false},
{ImGuiKey_R, XformKind::Rotate, false},
{ImGuiKey_S, XformKind::Scale, true}};
for (const auto& op : ops) {
if ((op.fly && fly) || !ImGui::IsKeyPressed(op.key, false)) continue;
enter_transform();
begin_xform(op.kind);
return;
}
}
if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
_tool.cancel();
_pick = Pick::None;
}
if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) ||
ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) {
ShapeStroke s;
@@ -242,6 +309,17 @@ void EditSession::draw_status() {
ui::Text(msg::working);
return;
}
if (_xform.active()) {
ui::TextDisabled(_xform.kind() == XformKind::Scale ? xmsg::hint_op_scale
: xmsg::hint_op);
return;
}
if (_pick != Pick::None) {
ui::TextDisabled(_pick == Pick::Ground ? xmsg::hint_pick_ground
: _pick == Pick::Corner ? xmsg::hint_pick_corner
: xmsg::hint_pick_origin);
return;
}
ui::TextDisabled(_tool.hint());
if (_tool.owns_pointer()) {
ImGui::SameLine();
@@ -253,8 +331,17 @@ void EditSession::draw_status() {
void EditSession::draw_panel() {
if (!_doc) return;
EditDoc& d = *_doc;
const float full = ImGui::GetContentRegionAvail().x;
const ImGuiStyle& st = ImGui::GetStyle();
// The body scrolls; the strip under it does not, and its height is
// reserved whether or not anything is in it. A banner that appears at the
// top of a panel moves every control out from under the cursor.
const float strip = ImGui::GetFrameHeightWithSpacing();
ImGui::BeginChild("##editbody", ImVec2(0, -strip));
// Measured INSIDE the child: its scrollbar takes width, and a row sized
// from outside runs under it.
const float full = ImGui::GetContentRegionAvail().x;
const float half = (full - st.ItemSpacing.x) * 0.5f;
const float third = (full - st.ItemSpacing.x * 2) / 3.0f;
@@ -269,12 +356,6 @@ void EditSession::draw_panel() {
const float slider_w =
std::max(full - label_w - st.ItemInnerSpacing.x, full * 0.3f);
// The body scrolls; the strip under it does not, and its height is
// reserved whether or not anything is in it. A banner that appears at the
// top of a panel moves every control out from under the cursor.
const float strip = ImGui::GetFrameHeightWithSpacing();
ImGui::BeginChild("##editbody", ImVec2(0, -strip));
// A long walk over the elements is in flight and every action below
// depends on what it finds.
ImGui::BeginDisabled(busy());
@@ -288,128 +369,168 @@ void EditSession::draw_panel() {
ui::help_on_hover(msg::layer_help);
}
// ---- tools ----
ui::SeparatorText(msg::sec_tool);
{
constexpr int kPerRow = 3;
const float w = (full - st.ItemSpacing.x * (kPerRow - 1)) / kPerRow;
int col = 0;
for (int i = 0; i < kNumTools; i++) {
if (col) ImGui::SameLine();
const ToolRow& row = tool_table()[i];
if (key_button(tool_label(row.id), w, row.key, _tool.id() == row.id))
_tool.set_id(row.id);
ui::help_on_hover(tool_hint(row.id));
if (++col == kPerRow) col = 0;
}
}
if (_tool.id() == ToolId::Brush) {
float r = _tool.brush_radius();
ImGui::SetNextItemWidth(slider_w);
if (ui::SliderFloat(msg::opt_brush_size, &r, 2.0f, 300.0f, "%.0f"))
_tool.set_brush_radius(r);
}
// ---- the set ----
ui::SeparatorText(msg::sec_select);
ui::Text(msg::stat_selected, {(long long)d.sel().count()});
ImGui::SameLine();
ui::TextDisabled(d.element_name());
if (act_button(Act::All, msg::act_all, third)) select_all(true);
ImGui::SameLine();
if (act_button(Act::None, msg::act_none, third)) select_all(false);
ImGui::SameLine();
if (act_button(Act::Invert, msg::act_invert, third)) invert_selection();
// What a new selection does to the one already there. The modifiers do
// the same thing, which is what the tooltip says rather than a mode.
{
const Msg* labels[kNumCombine] = {&msg::combine_replace, &msg::combine_add,
&msg::combine_subtract,
&msg::combine_intersect};
const Act acts[kNumCombine] = {Act::Replace, Act::Add, Act::Subtract,
Act::Intersect};
// Packed greedily: four of these do not fit on one line in a narrow
// panel, and the fourth going off the edge is how it used to look.
float x = 0.0f;
for (int i = 0; i < kNumCombine; i++) {
const char* key = act_row(acts[i]).key;
const float w = ImGui::GetFrameHeight() + st.ItemInnerSpacing.x +
ImGui::CalcTextSize(labels[i]->get()).x +
st.ItemInnerSpacing.x +
ImGui::CalcTextSize(key).x;
if (i && x + st.ItemSpacing.x + w <= full) {
ImGui::SameLine();
x += st.ItemSpacing.x + w;
} else {
x = w;
// Two jobs, two tabs: choosing part of the model, and placing all of it.
// History and saving are under both, because both end in them.
if (ImGui::BeginTabBar("##edittabs")) {
const bool force = _tab_force;
_tab_force = false;
if (ui::BeginTabItem(xmsg::tab_select,
force && _tab == 0 ? ImGuiTabItemFlags_SetSelected : 0)) {
if (_tab != 0 && !force) {
// Clicked: back to whatever tool was in hand before.
_tab = 0;
_pick = Pick::None;
_tool.set_id(_xform_return);
}
// ---- tools ----
ui::SeparatorText(msg::sec_tool);
{
constexpr int kPerRow = 3;
const float w = (full - st.ItemSpacing.x * (kPerRow - 1)) / kPerRow;
int col = 0;
for (int i = 0; i < kNumSelectTools; i++) {
if (col) ImGui::SameLine();
const ToolRow& row = tool_table()[i];
if (key_button(tool_label(row.id), w, row.key, _tool.id() == row.id)) {
_tool.set_id(row.id);
_pick = Pick::None;
}
ui::help_on_hover(tool_hint(row.id));
if (++col == kPerRow) col = 0;
}
if (ui::RadioButton(*labels[i], _combine == i)) _combine = i;
ImGui::SameLine(0.0f, st.ItemInnerSpacing.x);
ui::TextDisabledRaw(key);
}
ui::help_on_hover(msg::combine_help);
}
if (_tool.id() == ToolId::Brush) {
float r = _tool.brush_radius();
ImGui::SetNextItemWidth(slider_w);
if (ui::SliderFloat(msg::opt_brush_size, &r, 2.0f, 300.0f, "%.0f"))
_tool.set_brush_radius(r);
}
option_box(*this, msg::opt_front_only, &_opt.front_only);
ui::help_on_hover(msg::opt_front_only_help);
if (d.kind() == EditDoc::Kind::Splats) {
option_box(*this, msg::opt_by_extent, &_opt.by_extent);
ui::help_on_hover(msg::opt_by_extent_help);
}
option_box(*this, msg::opt_depth_limit, &_opt.depth_limit);
ui::help_on_hover(msg::opt_depth_limit_help);
if (_opt.depth_limit) {
option_slider(*this, msg::opt_depth_near, &_opt.near_frac, 0.0f, 1.0f,
"%.2f", slider_w);
option_slider(*this, msg::opt_depth_far, &_opt.far_frac, 0.0f, 1.0f,
"%.2f", slider_w);
}
// ---- the set ----
ui::SeparatorText(msg::sec_select);
ui::Text(msg::stat_selected, {(long long)d.sel().count()});
ImGui::SameLine();
ui::TextDisabled(d.element_name());
if (act_button(Act::All, msg::act_all, third)) select_all(true);
ImGui::SameLine();
if (act_button(Act::None, msg::act_none, third)) select_all(false);
ImGui::SameLine();
if (act_button(Act::Invert, msg::act_invert, third)) invert_selection();
if (act_button(Act::Grow, msg::act_grow, half)) grow_shrink(true);
ImGui::SameLine();
if (act_button(Act::Shrink, msg::act_shrink, half)) grow_shrink(false);
if (act_button(Act::Floaters, msg::act_floaters, full))
keep_largest_components();
ui::help_on_hover(msg::act_floaters_help);
// What a new selection does to the one already there. The modifiers do
// the same thing, which is what the tooltip says rather than a mode.
{
const Msg* labels[kNumCombine] = {&msg::combine_replace, &msg::combine_add,
&msg::combine_subtract,
&msg::combine_intersect};
const Act acts[kNumCombine] = {Act::Replace, Act::Add, Act::Subtract,
Act::Intersect};
// Packed greedily: four of these do not fit on one line in a narrow
// panel, and the fourth going off the edge is how it used to look.
float x = 0.0f;
for (int i = 0; i < kNumCombine; i++) {
const char* key = act_row(acts[i]).key;
const float w = ImGui::GetFrameHeight() + st.ItemInnerSpacing.x +
ImGui::CalcTextSize(labels[i]->get()).x +
st.ItemInnerSpacing.x +
ImGui::CalcTextSize(key).x;
if (i && x + st.ItemSpacing.x + w <= full) {
ImGui::SameLine();
x += st.ItemSpacing.x + w;
} else {
x = w;
}
if (ui::RadioButton(*labels[i], _combine == i)) _combine = i;
ImGui::SameLine(0.0f, st.ItemInnerSpacing.x);
ui::TextDisabledRaw(key);
}
ui::help_on_hover(msg::combine_help);
}
if (ui::CollapsingHeader(msg::sec_advanced)) {
option_slider(*this, msg::act_reach, &_radius_mul, 0.0f, 6.0f, "%.2f",
slider_w);
ui::help_on_hover(msg::act_reach_help);
option_slider_int(*this, msg::act_pieces_kept, &_keep_components, 1, 32,
option_box(*this, msg::opt_front_only, &_opt.front_only);
ui::help_on_hover(msg::opt_front_only_help);
if (d.kind() == EditDoc::Kind::Splats) {
option_box(*this, msg::opt_by_extent, &_opt.by_extent);
ui::help_on_hover(msg::opt_by_extent_help);
}
option_box(*this, msg::opt_depth_limit, &_opt.depth_limit);
ui::help_on_hover(msg::opt_depth_limit_help);
if (_opt.depth_limit) {
option_slider(*this, msg::opt_depth_near, &_opt.near_frac, 0.0f, 1.0f,
"%.2f", slider_w);
option_slider(*this, msg::opt_depth_far, &_opt.far_frac, 0.0f, 1.0f,
"%.2f", slider_w);
}
if (act_button(Act::Grow, msg::act_grow, half)) grow_shrink(true);
ImGui::SameLine();
if (act_button(Act::Shrink, msg::act_shrink, half)) grow_shrink(false);
if (act_button(Act::Floaters, msg::act_floaters, full))
keep_largest_components();
ui::help_on_hover(msg::act_floaters_help);
if (ui::CollapsingHeader(msg::sec_advanced)) {
option_slider(*this, msg::act_reach, &_radius_mul, 0.0f, 6.0f, "%.2f",
slider_w);
option_slider(*this, msg::opt_front_tol, &_opt.front_tol, 0.0f, 0.5f,
"%.3f", slider_w);
ui::help_on_hover(msg::opt_front_tol_help);
if (d.kind() == EditDoc::Kind::Splats)
option_slider(*this, msg::opt_extent_scale, &_opt.extent_scale,
0.25f, 4.0f, "%.2f", slider_w);
}
ui::help_on_hover(msg::act_reach_help);
option_slider_int(*this, msg::act_pieces_kept, &_keep_components, 1, 32,
slider_w);
option_slider(*this, msg::opt_front_tol, &_opt.front_tol, 0.0f, 0.5f,
"%.3f", slider_w);
ui::help_on_hover(msg::opt_front_tol_help);
if (d.kind() == EditDoc::Kind::Splats)
option_slider(*this, msg::opt_extent_scale, &_opt.extent_scale,
0.25f, 4.0f, "%.2f", slider_w);
}
// ---- what is done with it ----
ui::SeparatorText(msg::sec_actions);
ImGui::BeginDisabled(d.sel().empty());
if (act_button(Act::Delete, msg::act_delete, half)) {
d.run(make_hide_op(d, false));
if (ui::CollapsingHeader(amsg::sec_attribute)) draw_attribute_section(full);
if (d.colours_available()) {
if (ui::CollapsingHeader(amsg::sec_colour)) {
const ToolRow& k = tool_table()[(int)ToolId::Eyedropper];
if (key_button(tool_label(k.id), full, k.key,
_tool.id() == ToolId::Eyedropper))
_tool.set_id(_tool.id() == ToolId::Eyedropper
? ToolId::Navigate : ToolId::Eyedropper);
ui::help_on_hover(tool_hint(k.id));
draw_colour_section(full);
}
}
// ---- what is done with it ----
ui::SeparatorText(msg::sec_actions);
ImGui::BeginDisabled(d.sel().empty());
if (act_button(Act::Delete, msg::act_delete, half)) {
d.run(make_hide_op(d, false));
}
ui::help_on_hover_disabled(d.sel().empty() ? msg::stat_nothing_selected
: msg::act_delete_help);
ImGui::SameLine();
if (act_button(Act::Isolate, msg::act_isolate, half)) {
d.run(make_hide_op(d, true));
}
ui::help_on_hover_disabled(d.sel().empty() ? msg::stat_nothing_selected
: msg::act_isolate_help);
ImGui::EndDisabled();
const int64_t hidden = d.count() - d.alive_count();
ImGui::BeginDisabled(hidden == 0);
if (act_button(Act::Restore, msg::act_restore, full)) {
d.run(make_reveal_op(d));
}
ImGui::EndDisabled();
ui::Text(msg::stat_kept, {(long long)d.alive_count(), (long long)d.count()});
if (hidden) ui::TextDisabled(msg::stat_hidden, {(long long)hidden});
ImGui::EndTabItem();
}
if (ui::BeginTabItem(xmsg::tab_transform,
force && _tab == 1 ? ImGuiTabItemFlags_SetSelected : 0)) {
if (_tab != 1 && !force) enter_transform();
draw_transform_tab(full);
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
ui::help_on_hover_disabled(d.sel().empty() ? msg::stat_nothing_selected
: msg::act_delete_help);
ImGui::SameLine();
if (act_button(Act::Isolate, msg::act_isolate, half)) {
d.run(make_hide_op(d, true));
}
ui::help_on_hover_disabled(d.sel().empty() ? msg::stat_nothing_selected
: msg::act_isolate_help);
ImGui::EndDisabled();
const int64_t hidden = d.count() - d.alive_count();
ImGui::BeginDisabled(hidden == 0);
if (act_button(Act::Restore, msg::act_restore, full)) {
d.run(make_reveal_op(d));
}
ImGui::EndDisabled();
ui::Text(msg::stat_kept, {(long long)d.alive_count(), (long long)d.count()});
if (hidden) ui::TextDisabled(msg::stat_hidden, {(long long)hidden});
// ---- history ----
ui::SeparatorText(msg::sec_history);
@@ -545,4 +666,152 @@ void EditSession::draw_panel() {
}
}
// ---------------------------------------------------------------------------
// The Transform tab
// ---------------------------------------------------------------------------
void EditSession::draw_transform_tab(float full) {
EditDoc& d = *_doc;
const ImGuiStyle& st = ImGui::GetStyle();
const float half = (full - st.ItemSpacing.x) * 0.5f;
const float third = (full - st.ItemSpacing.x * 2) / 3.0f;
ui::TextDisabledWrapped(xmsg::tab_transform_help);
// ---- the three handles, and the keys that do the same without them ----
ui::SeparatorText(xmsg::sec_move);
const struct { XformKind kind; const Msg* name; const Msg* help; const char* key; } modes[] = {
{XformKind::Move, &xmsg::mode_move, &xmsg::mode_move_help, "G"},
{XformKind::Rotate, &xmsg::mode_rotate, &xmsg::mode_rotate_help, "R"},
{XformKind::Scale, &xmsg::mode_scale, &xmsg::mode_scale_help, "S"}};
for (int i = 0; i < 3; i++) {
if (i) ImGui::SameLine();
if (key_button(*modes[i].name, third, modes[i].key,
_xform_mode == modes[i].kind)) {
_xform_mode = modes[i].kind;
_pick = Pick::None;
}
ui::help_on_hover(*modes[i].help);
}
{
const Msg* pivots[4] = {&xmsg::pivot_origin, &xmsg::pivot_median,
&xmsg::pivot_mean, &xmsg::pivot_selection};
const float lw = ImGui::CalcTextSize(xmsg::pivot.get()).x;
ImGui::SetNextItemWidth(std::max(full - lw - st.ItemInnerSpacing.x, full * 0.4f));
if (ui::BeginCombo(xmsg::pivot, pivots[_pivot]->get())) {
for (int i = 0; i < 4; i++) {
// A selection that is empty has no middle to turn about.
if (i == (int)Pivot::Selection && d.sel().empty()) continue;
if (ui::Selectable(*pivots[i], i == _pivot)) _pivot = i;
}
ImGui::EndCombo();
}
ui::help_on_hover(xmsg::pivot_help);
if (_pivot == (int)Pivot::Selection && d.sel().empty())
_pivot = (int)Pivot::Median;
}
// A model that came in lying on its side is the commonest complaint, and
// it is one click: no dragging a ring to "about ninety".
ui::Text(xmsg::quarter_turns);
{
const char* axes[3] = {"X", "Y", "Z"};
const float w = (full - st.ItemSpacing.x * 5) / 6.0f;
for (int a = 0; a < 3; a++)
for (int neg = 0; neg < 2; neg++) {
if (a || neg) ImGui::SameLine();
const std::string label = std::string(axes[a]) + (neg ? " -90\xc2\xb0" : " +90\xc2\xb0");
ImGui::PushID(a * 2 + neg);
if (ui::ButtonRaw((label + "##quarter").c_str(), ImVec2(w, 0)))
quarter_turn(a, neg != 0);
ImGui::PopID();
}
ui::help_on_hover(xmsg::quarter_turns_help);
}
// ---- letting the model say where its floor is ----
ui::SeparatorText(xmsg::sec_align);
if (ui::Button(xmsg::auto_align, ImVec2(full, 0))) auto_align();
ui::help_on_hover(xmsg::auto_align_help);
auto pick_button = [&](Pick what, const Msg& name, const Msg& help, float w) {
if (key_button(name, w, nullptr, _pick == what))
_pick = _pick == what ? Pick::None : what;
ui::help_on_hover(help);
};
pick_button(Pick::Ground, xmsg::pick_ground, xmsg::pick_ground_help, half);
ImGui::SameLine();
pick_button(Pick::Corner, xmsg::pick_corner, xmsg::pick_corner_help, half);
pick_button(Pick::Origin, xmsg::pick_origin, xmsg::pick_origin_help, half);
ImGui::SameLine();
ImGui::BeginDisabled(d.sel_of(0).empty());
if (ui::Button(xmsg::ground_from_selection, ImVec2(half, 0))) ground_from_selection();
ImGui::EndDisabled();
ui::help_on_hover_disabled(xmsg::ground_from_selection_help);
if (ui::CollapsingHeader(xmsg::sec_align_options)) {
ui::Checkbox(xmsg::opt_align_yaw, &_align_yaw);
ui::help_on_hover(xmsg::opt_align_yaw_help);
ui::Checkbox(xmsg::opt_align_centre, &_align_centre);
ui::help_on_hover(xmsg::opt_align_centre_help);
ui::Checkbox(xmsg::opt_corner_origin, &_corner_to_origin);
ui::help_on_hover(xmsg::opt_corner_origin_help);
const float lw = ImGui::CalcTextSize(xmsg::opt_align_tol.get()).x;
ImGui::SetNextItemWidth(std::max(full - lw - st.ItemInnerSpacing.x, full * 0.3f));
ui::SliderFloat(xmsg::opt_align_tol, &_align_tol, 0.1f, 5.0f, "%.2f");
ui::help_on_hover(xmsg::opt_align_tol_help);
}
// ---- the numbers, in the file's own units ----
ui::SeparatorText(xmsg::sec_numbers);
{
const spirula::Sim3 now = d.file_placement();
if (!_fields_active) {
for (int k = 0; k < 3; k++) _placement_fields[k] = (float)now.t[k];
float e[3];
euler_of(now.R, e);
for (int k = 0; k < 3; k++) _placement_fields[3 + k] = e[k];
_placement_fields[6] = (float)now.s;
}
bool any_active = false, commit = false;
const float lw = std::max({ImGui::CalcTextSize(xmsg::field_position.get()).x,
ImGui::CalcTextSize(xmsg::field_rotation.get()).x,
ImGui::CalcTextSize(xmsg::field_scale.get()).x});
const float fw = (std::max(full - lw - st.ItemInnerSpacing.x, full * 0.5f) -
st.ItemInnerSpacing.x * 2) / 3.0f;
auto row = [&](const Msg& name, int first, int n, const char* fmt) {
for (int k = 0; k < n; k++) {
if (k) ImGui::SameLine(0.0f, st.ItemInnerSpacing.x);
ImGui::PushID(first + k);
ImGui::SetNextItemWidth(fw);
ui::InputFloatRaw("##pf", &_placement_fields[first + k], fmt);
any_active |= ImGui::IsItemActive();
commit |= ImGui::IsItemDeactivatedAfterEdit();
ImGui::PopID();
}
ImGui::SameLine(0.0f, st.ItemInnerSpacing.x);
ui::Text(name);
};
row(xmsg::field_position, 0, 3, "%.4g");
row(xmsg::field_rotation, 3, 3, "%.2f");
row(xmsg::field_scale, 6, 1, "%.4g");
ui::help_on_hover(xmsg::field_help);
_fields_active = any_active;
if (commit) {
spirula::Sim3 want;
for (int k = 0; k < 3; k++) want.t[k] = _placement_fields[k];
euler_to(&_placement_fields[3], want.R);
want.s = std::max((double)_placement_fields[6], 1e-6);
const spirula::Sim3 n = d.view_frame();
set_placement(n * want * n.inverse(), xmsg::op_set_numbers);
}
}
ImGui::BeginDisabled(d.placement().is_identity());
if (ui::Button(xmsg::reset_placement, ImVec2(full, 0)))
set_placement(spirula::Sim3(), xmsg::op_reset);
ImGui::EndDisabled();
if (_levelling_touched) ui::TextDisabledWrapped(xmsg::note_levelling);
if (d.kind() == EditDoc::Kind::Splats) ui::TextDisabledWrapped(xmsg::note_sh);
}
} // namespace gui
+120 -1
View File
@@ -2,9 +2,12 @@
#include "app/gui/edit/EditSession.h"
#include "app/gui/Layout.h"
#include "app/gui/ViewportPanel.h"
#include "i18n/Message.h"
#include "app/gui/edit/WorldGrid.h"
#include "i18n/catalog/Edit.h"
#include "i18n/catalog/EditTransform.h"
#include "imgui.h"
@@ -15,6 +18,8 @@
#include <numeric>
namespace msg = spirula::i18n::msg::edit;
namespace xmsg = spirula::i18n::msg::xform;
using spirula::Sim3;
namespace gui {
@@ -35,7 +40,26 @@ void EditSession::open(std::unique_ptr<EditDoc> doc, ViewportPanel* panel) {
// Opening in Navigate: the first thing anyone does with a model they have
// just opened is look at it from somewhere else.
_tool.set_id(ToolId::Navigate);
_tab = 0;
_tab_force = true;
_xform.cancel();
_pick = Pick::None;
_moved_ever = false;
_levelling_touched = false;
_saved_over_source = false;
_centres = Centres{};
_attrs.clear();
_attrs_layer = -1;
_hist_attr = -1;
_range_set = false;
_samples.clear();
_colours.clear();
_adjust_head = -1;
_adjust_live = false;
if (_doc) _doc->mark_geometry_dirty();
_seen_placement = Sim3();
_seen_head = 0;
push_placement();
}
void EditSession::close() {
@@ -48,10 +72,18 @@ void EditSession::close() {
_comp = Components{};
_pending.reset();
_fly_block_key = 0;
_xform.cancel();
_pick = Pick::None;
if (_panel) {
_panel->set_interactor(nullptr);
_panel->set_center_provider(nullptr);
// The placement was the editor's to show; what the pane goes back to
// is the file, as it was read.
static const float kIdentity[12] = {1,0,0,0, 0,1,0,0, 0,0,1,0};
_panel->set_edit_transform(kIdentity);
if (_levelling_touched) _panel->set_level_cameras(_levelling_was);
}
_levelling_touched = false;
if (_doc) _doc->revert_display();
if (_panel) _panel->invalidate();
_panel = nullptr;
@@ -86,6 +118,7 @@ bool EditSession::view(ViewProjection& out) const {
out.camera_model, out.eye);
out.cx = 0.5f * (float)out.W;
out.cy = 0.5f * (float)out.H;
out.ortho_back = _panel->ortho_pullback(false);
return true;
}
@@ -106,6 +139,8 @@ void EditSession::set_layer(int i) {
_occ_dirty = true;
_comp = Components{};
_pending.reset();
_adjust_head = -1;
_adjust_live = false;
_doc->mark_display_dirty();
}
@@ -116,12 +151,68 @@ void EditSession::set_layer(int i) {
bool EditSession::on_viewport_input(const ViewportInput& in) {
if (!_doc || busy()) return false;
// A running operator has the pointer whatever tool started it.
if (_xform.active()) {
XformFrame f;
if (!xform_frame(f)) {
_xform.cancel();
push_placement();
return true;
}
const TransformTool::Result r = _xform.update(in, f);
const Sim3 base = base_frame();
const Sim3 preview = base.inverse() * _xform.delta() * base * _xform_from;
if (r == TransformTool::Result::Confirmed) {
if (!_xform.delta().is_identity()) {
const spirula::i18n::Msg& name =
_xform.kind() == XformKind::Move ? xmsg::op_move
: _xform.kind() == XformKind::Rotate ? xmsg::op_rotate
: xmsg::op_scale;
set_placement(preview, name);
} else {
push_placement();
}
} else if (r == TransformTool::Result::Cancelled) {
push_placement();
} else if (_panel) {
float a[12];
preview.to_3x4(a);
_panel->set_edit_transform(a);
}
return true;
}
if (_tool.id() == ToolId::Transform) {
if (_pick != Pick::None) {
if (in.hovered && in.clicked) pick_align(in.x, in.y);
return in.down || in.clicked;
}
XformFrame f;
_xform_hot = -1;
if (in.hovered && xform_frame(f)) {
_xform_hot = _xform.hit_handle(_xform_mode, f, in.x, in.y);
if (_xform_hot >= 0 && in.clicked) {
_xform_from = _doc->placement();
const int axis = _xform_hot < 3 ? _xform_hot
: _xform_hot < 6 ? _xform_hot - 3 : -1;
_xform.begin(_xform_mode, f, in.x, in.y, /*drag=*/true, axis,
_xform_hot >= 3 && _xform_hot < 6);
return true;
}
}
// Off the handles the left button is still the camera's.
return false;
}
ShapeStroke s;
bool consumed = false;
if (_tool.update(in, s, consumed)) {
if (_tool.id() == ToolId::Piece)
select_component_under(s.pts[0], s.pts[1],
combine_now(in.shift, in.ctrl));
else if (_tool.id() == ToolId::Eyedropper)
pick_colour(s.pts[0], s.pts[1], in.shift);
else
apply_stroke(s, in);
}
@@ -130,7 +221,27 @@ bool EditSession::on_viewport_input(const ViewportInput& in) {
void EditSession::draw_viewport_overlay(const ViewportOverlay& v) {
if (!_doc) return;
_tool.draw_overlay(v.dl, ImVec2(v.x, v.y));
const ImVec2 origin(v.x, v.y);
XformFrame f;
const bool have = xform_frame(f);
if (have && v.grid && draws_world_grid()) {
float target[3] = {0, 0, 0};
if (_panel) _panel->nav_target(target);
const double focus[3] = {target[0], target[1], target[2]};
draw_world_grid(v.dl, origin, f.cam, saved_to_shared(), v.grid_cell, focus);
}
if (have && _xform.active()) {
_xform.draw_overlay(v.dl, origin, f);
// Beside the pointer, where the eyes already are.
const std::string text = _xform.readout(f);
const ImVec2 m = ImGui::GetIO().MousePos;
const ImVec2 at(m.x + px(18.0f), m.y + px(14.0f));
v.dl->AddText(ImVec2(at.x + 1, at.y + 1), IM_COL32(0, 0, 0, 220), text.c_str());
v.dl->AddText(at, IM_COL32(255, 255, 255, 255), text.c_str());
} else if (have && _tool.id() == ToolId::Transform && _pick == Pick::None) {
_xform.draw_handles(v.dl, origin, _xform_mode, f, _xform_hot);
}
_tool.draw_overlay(v.dl, origin);
}
void EditSession::apply_stroke(const ShapeStroke& s, const ViewportInput& in) {
@@ -461,6 +572,9 @@ void EditSession::poll() {
_save_worker.join();
if (_save_error.empty()) {
_doc->mark_saved();
if (_save_path == _doc->source_path() ||
_save_path == _doc->default_save_path(_save_target))
_saved_over_source = true;
_status = spirula::i18n::format(msg::saved_to,
{_doc->default_save_path(_save_target)});
_status_err = false;
@@ -475,6 +589,10 @@ void EditSession::poll() {
return;
}
handle_keys();
if (!_xform.active()) {
follow_history();
push_placement();
}
// The occlusion buffer belongs to one camera and one live set; either
// moving invalidates it, and rebuilding is the next selection's business
// rather than this frame's.
@@ -528,6 +646,7 @@ void EditSession::save_to(int target, const std::string& path) {
if (!_doc || path.empty() || busy()) return;
if (_save_worker.joinable()) _save_worker.join();
_save_error.clear();
_save_path = path;
_save_done = 0;
_save_total = std::max(1, _doc->save_steps(target));
_save_busy = true;
+112 -2
View File
@@ -9,10 +9,12 @@
// renders.
#include "app/gui/ViewportInput.h"
#include "app/gui/edit/Attributes.h"
#include "app/gui/edit/EditDoc.h"
#include "app/gui/edit/EditTool.h"
#include "app/gui/edit/ElementGrid.h"
#include "app/gui/edit/SelectShape.h"
#include "app/gui/edit/TransformTool.h"
#include <atomic>
#include <functional>
@@ -84,13 +86,23 @@ public:
// One line under the viewport: the active tool and its keys.
void draw_status();
bool owns_left_button() const override { return _tool.owns_pointer(); }
bool owns_left_button() const override {
return _tool.owns_pointer() || _xform.active() || _pick != Pick::None;
}
// The letter that switched tools is usually still down on the frame the
// switch takes effect, so Q would both select Navigate and fly the camera
// once. The block lifts when that key comes up.
bool blocks_fly_keys() const override {
return _tool.owns_pointer() || _fly_block_key != 0;
return _tool.owns_keys() || _xform.active() || _fly_block_key != 0;
}
bool owns_right_button() const override { return _xform.active(); }
// Once the model has been moved the renderers' own grid is in the wrong
// place, so from then on the grid is this session's to draw.
bool draws_world_grid() const override;
// Whether what is on disk still matches the pane that was loaded from it:
// false once a save went over the source, which is the owner's cue to
// read the file again when the edit ends.
bool saved_over_source() const { return _saved_over_source; }
bool on_viewport_input(const ViewportInput& in) override;
void draw_viewport_overlay(const ViewportOverlay& v) override;
// A long job is in flight; editing waits for it.
@@ -102,6 +114,50 @@ public:
int work_total() const { return _save_total.load(); }
private:
// ---- placing the model (EditTransform.cpp) ----
enum class Pivot { Origin = 0, Median, Mean, Selection };
enum class Pick { None = 0, Ground, Corner, Origin };
// Saved coordinates (the file's, placement applied) -> the shared frame.
spirula::Sim3 saved_to_shared() const;
spirula::Sim3 base_frame() const;
bool xform_frame(XformFrame& f);
bool pointer_in_view(float& x, float& y) const;
void pivot_model(double out[3]);
void begin_xform(XformKind kind);
// A step made in the shared frame / in saved coordinates, as one history
// entry.
void place_shared(const spirula::Sim3& step, const spirula::i18n::Msg& name);
// `carry` takes the view along: an alignment is the WORLD being defined
// under the model, and a model that leaves the screen looks like a bug.
void place_saved(const spirula::Sim3& step, const spirula::i18n::Msg& name,
bool carry = false);
void set_placement(const spirula::Sim3& next, const spirula::i18n::Msg& name,
bool carry = false);
void push_placement();
// Undo and redo across an alignment take the view with them too.
void follow_history();
void quarter_turn(int axis, bool negative);
void auto_align();
void ground_from_selection();
void pick_align(float px, float py);
// Live layer-0 points in saved coordinates, thinned to at most `cap`.
std::vector<double> saved_points(int64_t cap, bool selected_only,
std::vector<int64_t>* index = nullptr) const;
void enter_transform();
void draw_transform_tab(float full);
// ---- selecting by attribute and by colour (EditAttributes.cpp) ----
void draw_attribute_section(float full);
void draw_colour_section(float full);
void refresh_attribute();
// A selection that replaces the last one of the same kind rather than
// stacking on it: dragging a range again is an adjustment, not a new step.
void begin_adjustable(int kind);
void preview_adjustable(const std::vector<uint8_t>& w);
void commit_adjustable(const std::vector<uint8_t>& w, const std::string& label);
void pick_colour(float px, float py, bool append);
void run_colour(bool commit);
void apply_stroke(const ShapeStroke& s, const ViewportInput& in);
// Compute the selection a recipe describes and either record it as a step
// or write it straight in -- a setting change is its own step, so the
@@ -172,6 +228,60 @@ private:
std::atomic<bool> _save_busy{false};
std::atomic<int> _save_done{0}, _save_total{0};
std::string _save_error;
std::string _save_path;
// ---- placement ----
TransformTool _xform;
XformKind _xform_mode = XformKind::Move;
int _xform_hot = -1;
spirula::Sim3 _xform_from; // the placement the running operator began at
spirula::Sim3 _seen_placement; // as of the last frame, for follow_history
int _seen_head = 0;
ToolId _xform_return = ToolId::Navigate;
int _pivot = (int)Pivot::Median;
Pick _pick = Pick::None;
bool _align_yaw = true, _align_centre = true, _corner_to_origin = false;
float _align_tol = 1.0f; // x 1% of the model's extent
bool _levelling_was = false, _levelling_touched = false;
bool _moved_ever = false; // the grid is this session's from here on
struct Centres {
int layer = -1;
int64_t alive = -1, selected = -1;
uint64_t sel_rev = 0;
double median[3] = {0, 0, 0}, mean[3] = {0, 0, 0}, sel[3] = {0, 0, 0};
} _centres;
float _placement_fields[7] = {0, 0, 0, 0, 0, 0, 1}; // t, euler deg, s
bool _fields_active = false;
// ---- attributes ----
std::vector<Attr> _attrs; // what the current layer offers
int _attrs_layer = -1;
int _attr = 0; // index into _attrs
std::vector<float> _attr_values;
AttrHistogram _hist;
uint64_t _hist_rev = 0;
int _hist_attr = -1;
bool _hist_log_counts = true;
bool _range_outside = false;
double _range[2] = {0.25, 0.75}; // fractions of the histogram's axis
bool _range_set = false;
int _range_drag = 0; // 0 none, 1 low edge, 2 high edge, 3 new
double _range_anchor = 0.0;
// Colour samples, display-referred RGB, and how close is close.
std::vector<float> _samples;
std::vector<float> _colours;
uint64_t _colours_key = 0;
float _colour_tol = 0.08f, _colour_light = 1.0f;
// The adjustable selection in flight: what it started from, and which
// history position it left behind when it was last committed.
std::vector<uint8_t> _adjust_before;
int _adjust_kind = 0, _adjust_head = -1;
bool _adjust_live = false;
double _preview_at = 0.0;
int _tab = 0; // 0 select, 1 transform
bool _tab_force = false;
bool _saved_over_source = false;
std::function<void(int, const std::string&, bool, const std::string&)>
_pick_save;
+12 -2
View File
@@ -3,12 +3,16 @@
#include "app/gui/edit/EditTool.h"
#include "i18n/catalog/Edit.h"
#include "i18n/catalog/EditAttributes.h"
#include "i18n/catalog/EditTransform.h"
#include "imgui.h"
#include <cmath>
namespace msg = spirula::i18n::msg::edit;
namespace xmsg = spirula::i18n::msg::xform;
namespace amsg = spirula::i18n::msg::attr;
namespace gui {
@@ -43,6 +47,8 @@ const ToolRow* tool_table() {
{ToolId::Polygon, "P", ImGuiKey_P, false},
{ToolId::Brush, "C", ImGuiKey_C, false},
{ToolId::Piece, "F", ImGuiKey_F, false},
{ToolId::Transform, "T", ImGuiKey_T, false},
{ToolId::Eyedropper, "K", ImGuiKey_K, false},
};
return rows;
}
@@ -55,6 +61,8 @@ const spirula::i18n::Msg& tool_label(ToolId t) {
case ToolId::Polygon: return msg::tool_polygon;
case ToolId::Brush: return msg::tool_brush;
case ToolId::Piece: return msg::tool_piece;
case ToolId::Transform: return xmsg::tool_transform;
case ToolId::Eyedropper: return amsg::tool_eyedropper;
default: return msg::tool_navigate;
}
}
@@ -67,6 +75,8 @@ const spirula::i18n::Msg& tool_hint(ToolId t) {
case ToolId::Polygon: return msg::hint_polygon;
case ToolId::Brush: return msg::hint_brush;
case ToolId::Piece: return msg::hint_piece;
case ToolId::Transform: return xmsg::hint_transform;
case ToolId::Eyedropper: return amsg::hint_eyedropper;
default: return msg::hint_navigate;
}
}
@@ -91,11 +101,11 @@ bool EditTool::pop_point() {
bool EditTool::update(const ViewportInput& in, ShapeStroke& out, bool& consumed) {
consumed = false;
if (_id == ToolId::Navigate) return false;
if (_id == ToolId::Navigate || _id == ToolId::Transform) return false;
_cur[0] = in.x;
_cur[1] = in.y;
if (_id == ToolId::Piece) {
if (_id == ToolId::Piece || _id == ToolId::Eyedropper) {
if (in.hovered && in.clicked) {
consumed = true;
out.kind = ShapeKind::Box;
+13 -3
View File
@@ -22,16 +22,26 @@ namespace spirula { namespace i18n { struct Msg; } }
namespace gui {
// The first kNumSelectTools are what the Select tab lays out; Transform and
// Eyedropper are reached from their own parts of the panel.
enum class ToolId {
Navigate = 0, Box, Ellipse, Lasso, Polygon, Brush, Piece
Navigate = 0, Box, Ellipse, Lasso, Polygon, Brush, Piece,
Transform, Eyedropper
};
inline constexpr int kNumTools = 7;
inline constexpr int kNumSelectTools = 7;
inline constexpr int kNumTools = 9;
class EditTool {
public:
ToolId id() const { return _id; }
void set_id(ToolId t);
bool owns_pointer() const { return _id != ToolId::Navigate; }
// The left button: Transform leaves it to the camera except over a
// handle, which is the session's call and not a property of the tool.
bool owns_pointer() const {
return _id != ToolId::Navigate && _id != ToolId::Transform;
}
// The letter keys, which Transform does take: S has to mean scale.
bool owns_keys() const { return _id != ToolId::Navigate; }
bool in_progress() const { return _active; }
float brush_radius() const { return _brush; }
+466
View File
@@ -0,0 +1,466 @@
// EditTransform.cpp -- the editing session's placement half: the modal
// operator's glue, the alignment helpers and the Transform tab. The session
// is in EditSession.h; the frames are written out in
// docs/notes/scene-transform.md.
#include "app/gui/edit/EditSession.h"
#include "app/gui/Layout.h"
#include "app/gui/Ui.h"
#include "app/gui/ViewportPanel.h"
#include "app/gui/edit/AlignFit.h"
#include "app/gui/edit/WorldGrid.h"
#include "i18n/catalog/Edit.h"
#include "i18n/catalog/EditTransform.h"
#include "imgui.h"
#include <algorithm>
#include <cmath>
namespace msg = spirula::i18n::msg::xform;
namespace emsg = spirula::i18n::msg::edit;
using spirula::Sim3;
using spirula::i18n::Msg;
namespace gui {
namespace {
constexpr double kPi = 3.14159265358979323846;
} // namespace
// ---------------------------------------------------------------------------
// Frames
// ---------------------------------------------------------------------------
Sim3 EditSession::base_frame() const {
float b[12] = {1,0,0,0, 0,1,0,0, 0,0,1,0};
if (_panel) _panel->base_transform(b);
return Sim3::from_3x4(b);
}
Sim3 EditSession::saved_to_shared() const {
return base_frame() * _doc->view_frame();
}
bool EditSession::draws_world_grid() const {
return _doc && (_moved_ever || _xform.active() || !_doc->placement().is_identity());
}
bool EditSession::pointer_in_view(float& x, float& y) const {
if (!_panel) return false;
float ix, iy, iw, ih;
_panel->image_rect(ix, iy, iw, ih);
const ImVec2 m = ImGui::GetIO().MousePos;
x = m.x - ix;
y = m.y - iy;
return iw > 8 && ih > 8 && x >= 0 && y >= 0 && x < iw && y < ih;
}
// The pivot in the positions() frame. Medians are not free over a million
// elements, so they are taken when the live set or the selection changes.
void EditSession::pivot_model(double out[3]) {
out[0] = out[1] = out[2] = 0.0;
const Pivot want = (Pivot)_pivot;
if (want == Pivot::Origin) {
// The point whose SAVED coordinate is the origin: saved = N^-1 E p,
// so p = E^-1 N 0.
const double zero[3] = {0, 0, 0};
double n0[3];
_doc->view_frame().apply(zero, n0);
_doc->placement().inverse().apply(n0, out);
return;
}
const int64_t n = _doc->count();
const uint8_t* alive = _doc->alive();
const Selection& sel = _doc->sel();
if (_centres.layer != _doc->layer() || _centres.alive != _doc->alive_count() ||
_centres.selected != sel.count() || _centres.sel_rev != _doc->revision()) {
_centres.layer = _doc->layer();
_centres.alive = _doc->alive_count();
_centres.selected = sel.count();
_centres.sel_rev = _doc->revision();
const float* p = _doc->positions();
const int64_t step = std::max<int64_t>(1, n / 400000);
std::vector<float> ax[3], sx[3];
double sum[3] = {0, 0, 0};
int64_t m = 0;
for (int64_t i = 0; i < n; i += step) {
if (!alive[i]) continue;
for (int k = 0; k < 3; k++) {
ax[k].push_back(p[i*3+k]);
sum[k] += p[i*3+k];
if (sel.selected(i)) sx[k].push_back(p[i*3+k]);
}
m++;
}
for (int k = 0; k < 3; k++) {
_centres.mean[k] = m ? sum[k] / (double)m : 0.0;
auto mid = [](std::vector<float>& v) {
if (v.empty()) return 0.0;
std::nth_element(v.begin(), v.begin() + v.size() / 2, v.end());
return (double)v[v.size() / 2];
};
_centres.median[k] = mid(ax[k]);
_centres.sel[k] = sx[k].empty() ? _centres.median[k] : mid(sx[k]);
}
}
const double* c = want == Pivot::Mean ? _centres.mean
: want == Pivot::Selection ? _centres.sel : _centres.median;
for (int k = 0; k < 3; k++) out[k] = c[k];
}
bool EditSession::xform_frame(XformFrame& f) {
if (!_panel || !_doc) return false;
float x, y, w, h;
_panel->image_rect(x, y, w, h);
if (w < 8.0f || h < 8.0f) return false;
f.cam.W = (int)w;
f.cam.H = (int)h;
_panel->nav_camera(f.cam.W, f.cam.H, f.cam.w2c, f.cam.fx, f.cam.fy,
f.cam.camera_model, f.cam.eye);
f.cam.cx = 0.5f * (float)f.cam.W;
f.cam.cy = 0.5f * (float)f.cam.H;
const Sim3 base = base_frame();
// While the operator runs the pivot stays where it was when it began.
const Sim3& placed = _xform.active() ? _xform_from : _doc->placement();
double pm[3], q[3];
pivot_model(pm);
placed.apply(pm, q);
base.apply(q, f.pivot);
const Sim3 g = base * _doc->view_frame();
const Sim3 l = base * placed * _doc->view_frame();
for (int a = 0; a < 3; a++)
for (int k = 0; k < 3; k++) {
f.global_axes[a*3+k] = g.R[k*3+a];
f.local_axes[a*3+k] = l.R[k*3+a];
}
f.unit = g.s;
f.grid_cell = _panel->world_grid_cell();
return true;
}
// ---------------------------------------------------------------------------
// Steps
// ---------------------------------------------------------------------------
void EditSession::push_placement() {
if (!_panel || !_doc) return;
float a[12];
_doc->placement().to_3x4(a);
_panel->set_edit_transform(a);
}
void EditSession::set_placement(const Sim3& next, const Msg& name, bool carry) {
if (!_doc) return;
Sim3 clean = next;
clean.orthonormalize();
const Sim3 was = _doc->placement();
_doc->run(make_placement_op(*_doc, clean, name.get(), carry));
_moved_ever = true;
push_placement();
if (carry && _panel) {
// shared' = base * next * was^-1 * base^-1 * shared.
const Sim3 base = base_frame();
float a[12];
(base * clean * was.inverse() * base.inverse()).to_3x4(a);
_panel->carry_view(a);
}
_seen_placement = _doc->placement();
_seen_head = _doc->history_head();
}
void EditSession::follow_history() {
if (!_doc || !_panel) return;
const int head = _doc->history_head();
if (head == _seen_head) return;
bool carried = false;
const auto& ops = _doc->history();
for (int i = std::min(head, _seen_head); i < std::max(head, _seen_head); i++)
if (i >= 0 && i < (int)ops.size() && ops[(size_t)i]->carries_view())
carried = true;
if (carried) {
const Sim3 base = base_frame();
float a[12];
(base * _doc->placement() * _seen_placement.inverse() * base.inverse()).to_3x4(a);
_panel->carry_view(a);
}
_seen_head = head;
_seen_placement = _doc->placement();
}
// shared' = step * shared, and shared = base * placement * p.
void EditSession::place_shared(const Sim3& step, const Msg& name) {
const Sim3 base = base_frame();
set_placement(base.inverse() * step * base * _doc->placement(), name);
}
// saved' = step * saved, and saved = N^-1 * placement * p.
void EditSession::place_saved(const Sim3& step, const Msg& name, bool carry) {
const Sim3 n = _doc->view_frame();
set_placement(n * step * n.inverse() * _doc->placement(), name, carry);
}
void EditSession::begin_xform(XformKind kind) {
if (!_doc || busy() || _xform.active()) return;
_xform_mode = kind;
XformFrame f;
float mx, my;
// Started from the keyboard, so it begins where the pointer is; with the
// pointer somewhere else the key still picks which handles are shown.
if (!xform_frame(f) || !pointer_in_view(mx, my)) return;
_xform_from = _doc->placement();
_xform.begin(kind, f, mx, my, /*drag=*/false);
}
void EditSession::quarter_turn(int axis, bool negative) {
XformFrame f;
if (!xform_frame(f)) return;
const Sim3 step = Sim3::rotation_about(f.global_axes + axis * 3,
negative ? -kPi / 2 : kPi / 2, f.pivot);
place_shared(step, msg::op_quarter_turn);
}
void EditSession::enter_transform() {
if (!_doc) return;
if (_tool.id() != ToolId::Transform) _xform_return = _tool.id();
_tool.set_id(ToolId::Transform);
_tab = 1;
_tab_force = true;
// The axes that get SAVED are the file's own. With the parsers' levelling
// guess on top, the model would look upright and save tilted.
if (_panel && _panel->has_levelling() && _panel->level_cameras()) {
_levelling_was = true;
_levelling_touched = true;
_panel->set_level_cameras(false);
}
}
// ---------------------------------------------------------------------------
// Alignment
// ---------------------------------------------------------------------------
std::vector<double> EditSession::saved_points(int64_t cap, bool selected_only,
std::vector<int64_t>* index) const {
std::vector<double> out;
// Layer 0 whatever is current: a camera table is not a surface.
const std::vector<uint8_t>& alive = _doc->alive_of(0);
const Selection& sel = _doc->sel_of(0);
const float* p = _doc->positions_of(0);
const int64_t n = (int64_t)alive.size();
const int64_t want = selected_only ? sel.count() : _doc->alive_count_of(0);
const int64_t step = std::max<int64_t>(1, want / std::max<int64_t>(cap, 1));
const Sim3 to_saved = _doc->view_frame().inverse() * _doc->placement();
// What the user selected is what they meant, haze and all.
const float* solid = selected_only ? nullptr : _doc->solidity();
int64_t seen = 0;
for (int64_t i = 0; i < n; i++) {
if (!alive[(size_t)i] || (selected_only && !sel.selected(i))) continue;
if (solid && solid[i] < 0.3f) continue;
if (seen++ % step) continue;
const double q[3] = {p[i*3], p[i*3+1], p[i*3+2]};
double w[3];
to_saved.apply(q, w);
out.insert(out.end(), w, w + 3);
if (index) index->push_back(i);
}
return out;
}
void EditSession::auto_align() {
if (!_doc || busy()) return;
std::vector<int64_t> index;
const std::vector<double> pts = saved_points(250000, false, &index);
const int64_t n = (int64_t)pts.size() / 3;
if (n < 16) return;
const Sim3 to_saved = _doc->view_frame().inverse() * _doc->placement();
double up[3];
float hint[3];
const bool have_up = _doc->up_hint(hint);
if (have_up) {
const double h[3] = {hint[0], hint[1], hint[2]};
to_saved.rotate(h, up);
}
std::vector<float> normals, weights, all_n, all_w;
if (_doc->normals(all_n, all_w)) {
normals.resize((size_t)n * 3);
weights.resize((size_t)n);
for (int64_t k = 0; k < n; k++) {
const int64_t i = index[(size_t)k];
const double v[3] = {all_n[(size_t)i*3], all_n[(size_t)i*3+1],
all_n[(size_t)i*3+2]};
double w[3];
to_saved.rotate(v, w);
for (int r = 0; r < 3; r++) normals[(size_t)k*3+r] = (float)w[r];
weights[(size_t)k] = all_w[(size_t)i];
}
}
align::AutoAlignOptions opt;
opt.tol = 0.01 * _align_tol * (double)_doc->extent() * to_saved.s;
opt.yaw = _align_yaw;
opt.centre = _align_centre;
const align::AutoAlignResult r = align::auto_align(
pts.data(), n, have_up ? up : nullptr,
normals.empty() ? nullptr : normals.data(),
weights.empty() ? nullptr : weights.data(), opt);
place_saved(r.T, msg::op_auto_align, /*carry=*/true);
_status = spirula::i18n::format(
r.ground ? (r.walls ? msg::align_found_both : msg::align_found_ground)
: msg::align_found_nothing,
{(long long)std::lround(r.ground_share * 100.0)});
_status_err = !r.ground;
note(_status);
}
void EditSession::ground_from_selection() {
if (!_doc || busy() || _doc->sel_of(0).empty()) return;
const std::vector<double> pts = saved_points(250000, true);
const int64_t n = (int64_t)pts.size() / 3;
const Sim3 to_saved = _doc->view_frame().inverse() * _doc->placement();
align::Plane pl;
if (n < 8 || !align::fit_plane(pts.data(), n,
0.01 * _align_tol * (double)_doc->extent() * to_saved.s,
7u, pl)) {
_status = msg::align_no_surface.get();
_status_err = true;
return;
}
// Which way is up is whichever way is nearer to what up is now: a floor
// selected from above and one selected from below are the same floor.
if (pl.n[2] < 0) {
for (double& v : pl.n) v = -v;
pl.d = -pl.d;
}
double R[9];
const double zaxis[3] = {0, 0, 1};
align::rotation_between(pl.n, zaxis, R);
Sim3 step;
for (int i = 0; i < 9; i++) step.R[i] = R[i];
step.t[2] = pl.d;
place_saved(step, msg::op_ground, /*carry=*/true);
}
void EditSession::pick_align(float px_, float py_) {
const Pick what = _pick;
ViewProjection vp;
if (!_doc || !view(vp)) return;
// Surfaces are made of layer 0; a click while the camera layer is
// current still means the points under it.
const int was = _doc->layer();
_doc->set_layer(0);
const int64_t hit = pick_element(*_doc, vp, px_, py_, 16.0f);
_doc->set_layer(was);
if (hit < 0) {
_status = msg::align_no_surface.get();
_status_err = true;
return;
}
const Sim3 to_saved = _doc->view_frame().inverse() * _doc->placement();
const float* p = _doc->positions_of(0) + hit * 3;
const double pm[3] = {p[0], p[1], p[2]};
double at[3], eye[3];
to_saved.apply(pm, at);
// view() reports the camera in the positions() frame, like the elements.
const double em[3] = {vp.eye[0], vp.eye[1], vp.eye[2]};
to_saved.apply(em, eye);
_pick = Pick::None;
if (what == Pick::Origin) {
const double back[3] = {-at[0], -at[1], -at[2]};
place_saved(Sim3::translation(back), msg::op_set_origin, /*carry=*/true);
return;
}
const std::vector<double> pts = saved_points(1500000, false);
const int64_t n = (int64_t)pts.size() / 3;
const double r0 = 0.04 * (double)_doc->extent() * to_saved.s;
if (what == Pick::Ground) {
align::Plane pl;
if (!align::fit_plane_at(pts.data(), n, at, r0, pl)) {
_status = msg::align_no_surface.get();
_status_err = true;
return;
}
// A floor is looked at from above: its up is the side the eye is on.
const double to_eye[3] = {eye[0]-at[0], eye[1]-at[1], eye[2]-at[2]};
if (pl.n[0]*to_eye[0] + pl.n[1]*to_eye[1] + pl.n[2]*to_eye[2] < 0) {
for (double& v : pl.n) v = -v;
pl.d = -pl.d;
}
double R[9];
const double zaxis[3] = {0, 0, 1};
align::rotation_between(pl.n, zaxis, R);
Sim3 step;
for (int i = 0; i < 9; i++) step.R[i] = R[i];
step.t[2] = pl.d;
// Turn about the clicked point's own column, so the floor drops onto
// the grid under where it was rather than swinging away from it.
double moved[3];
step.apply(at, moved);
step.t[0] += at[0] - moved[0];
step.t[1] += at[1] - moved[1];
place_saved(step, msg::op_ground, /*carry=*/true);
return;
}
double axes[9], corner[3];
const int m = align::fit_corner(pts.data(), n, at, r0 * 2.5, axes, corner);
if (m == 0) {
_status = msg::align_no_surface.get();
_status_err = true;
return;
}
// Each surface goes to the axis it is already nearest, the best-measured
// first, so the model turns as little as it can.
int target[3] = {-1, -1, -1};
double sign[3] = {1, 1, 1};
bool used[3] = {false, false, false};
for (int r = 0; r < 3; r++) {
int best = -1;
double best_d = -1.0;
for (int k = 0; k < 3; k++) {
if (used[k]) continue;
const double d = std::fabs(axes[r*3+k]);
if (d > best_d) { best_d = d; best = k; }
}
target[r] = best;
used[best] = true;
sign[r] = axes[r*3+best] < 0 ? -1.0 : 1.0;
}
// R = T^t A sends row r of A to the signed axis T holds for it. The last
// row is the one the fit constructed, so it is the one that gives way
// when the three signs do not make a rotation.
auto build = [&](double R[9]) {
for (int i = 0; i < 9; i++) R[i] = 0.0;
for (int r = 0; r < 3; r++)
for (int k = 0; k < 3; k++)
R[target[r]*3+k] += sign[r] * axes[r*3+k];
};
double R[9];
build(R);
const double det = R[0]*(R[4]*R[8]-R[5]*R[7]) - R[1]*(R[3]*R[8]-R[5]*R[6]) +
R[2]*(R[3]*R[7]-R[4]*R[6]);
if (det < 0) {
sign[2] = -sign[2];
build(R);
}
Sim3 step;
for (int i = 0; i < 9; i++) step.R[i] = R[i];
double moved[3];
step.apply(corner, moved);
for (int k = 0; k < 3; k++)
step.t[k] = _corner_to_origin ? -moved[k] : corner[k] - moved[k];
place_saved(step, msg::op_corner, /*carry=*/true);
_status = spirula::i18n::format(msg::align_corner_found, {(long long)m});
_status_err = false;
}
} // namespace gui
+63 -3
View File
@@ -123,12 +123,31 @@ namespace {
// Rewrite one of them, dropping the faces `dropped` names. Its own colours,
// UVs and texture ride along untouched.
void filter_sibling(const std::string& path, const FaceCut& cut) {
void move_mesh(meshing::MeshData& m, const spirula::Sim3& T) {
if (T.is_identity()) return;
for (auto& v : m.V) {
const double p[3] = {v[0], v[1], v[2]};
double q[3];
T.apply(p, q);
v = {(float)q[0], (float)q[1], (float)q[2]};
}
for (auto& nrm : m.N) {
const double p[3] = {nrm[0], nrm[1], nrm[2]};
double q[3];
T.rotate(p, q);
nrm = {(float)q[0], (float)q[1], (float)q[2]};
}
}
void filter_sibling(const std::string& path, const FaceCut& cut,
const spirula::Sim3& moved) {
meshing::MeshData m;
std::string err;
if (!meshing::read_mesh(path, m, err)) throw std::runtime_error(err);
meshing::MeshData out;
// Matched in the coordinates the file was written in, moved after.
mesh_drop_faces(m, cut, out);
move_mesh(out, moved);
meshing::MeshColorMode mode = meshing::MeshColorMode::None;
if (!out.UV.empty() && !out.texture.empty())
mode = meshing::MeshColorMode::Texture;
@@ -354,6 +373,8 @@ void MeshDoc::save(int target, const std::string& path,
out.tex_width = _m.tex_width;
out.tex_height = _m.tex_height;
out.texture = _m.texture;
const spirula::Sim3 moved = file_placement();
move_mesh(out, moved);
meshing::MeshColorMode mode = meshing::MeshColorMode::None;
if (!out.UV.empty() && !out.texture.empty())
@@ -375,11 +396,50 @@ void MeshDoc::save(int target, const std::string& path,
// leaves them too -- matched by position, since the atlas renumbers.
if (!_link || _siblings.empty()) return;
const FaceCut cut = dropped_faces();
if (cut.empty()) return;
if (cut.empty() && moved.is_identity()) return;
for (const std::string& s : _siblings) {
filter_sibling(s, cut);
filter_sibling(s, cut, moved);
if (progress) (*progress)++;
}
}
bool MeshDoc::colours_available() const {
return textured(_m) || _m.C.size() == _m.V.size();
}
bool MeshDoc::colours(std::vector<float>& rgb) const {
const bool has_uv = textured(_m);
if (!has_uv && _m.C.size() != _m.V.size()) return false;
rgb.resize(_m.V.size() * 3);
for (size_t i = 0; i < _m.V.size(); i++) {
const std::array<unsigned char, 3> c =
has_uv ? sample_texture(_m, i) : _m.C[i];
for (int k = 0; k < 3; k++) rgb[i * 3 + k] = c[(size_t)k] / 255.0f;
}
return true;
}
bool MeshDoc::normals(std::vector<float>& n, std::vector<float>& w) const {
if (_m.N.size() != _m.V.size()) return false;
const size_t num = _m.V.size();
n.resize(num * 3);
w.assign(num, 0.0f);
for (size_t i = 0; i < num; i++)
for (int r = 0; r < 3; r++) n[i * 3 + r] = _m.N[i][r];
// A vertex speaks for a third of every face it is on.
for (const auto& f : _m.F) {
const auto& a = _m.V[(size_t)f[0]];
const auto& b = _m.V[(size_t)f[1]];
const auto& c = _m.V[(size_t)f[2]];
const float e1[3] = {b[0]-a[0], b[1]-a[1], b[2]-a[2]};
const float e2[3] = {c[0]-a[0], c[1]-a[1], c[2]-a[2]};
const float cx = e1[1]*e2[2] - e1[2]*e2[1];
const float cy = e1[2]*e2[0] - e1[0]*e2[2];
const float cz = e1[0]*e2[1] - e1[1]*e2[0];
const float area = 0.5f * std::sqrt(cx*cx + cy*cy + cz*cz) / 3.0f;
for (int k = 0; k < 3; k++) w[(size_t)f[k]] += area;
}
return true;
}
} // namespace gui
+6
View File
@@ -60,6 +60,12 @@ public:
void set_linked(bool on) override { _link = on; }
std::string default_save_path(int target) const override;
void revert_display() override;
spirula::Sim3 view_frame() const override {
return spirula::Sim3::from_3x4(_t2n);
}
bool normals(std::vector<float>& n, std::vector<float>& w) const override;
bool colours(std::vector<float>& rgb) const override;
bool colours_available() const override;
// The other files one meshing run wrote: the same surface in another
// format, so a face deleted here can be deleted there too.
+57 -2
View File
@@ -193,6 +193,56 @@ bool PointsDoc::live_centers(dsparse::CenterTable& out) const {
return true;
}
// RAW file coordinates -> the normalized frame: the parser's centring shift,
// then the inverse of train_to_normalized.
spirula::Sim3 PointsDoc::view_frame() const {
double A[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
if (_ds.train_frame_scale != 1.0f) {
double T[16];
for (int i = 0; i < 16; i++) T[i] = _ds.train_to_normalized[i];
dsparse::invert_affine4x4(T, A);
}
spirula::Sim3 shift;
for (int i = 0; i < 3; i++) shift.t[i] = -_ds.center[(size_t)i];
return spirula::Sim3::from_3x4(A) * shift;
}
bool PointsDoc::up_hint(float up[3]) const {
if (layer_count() <= kCameras) return false;
const std::vector<uint8_t>& ck = alive_of(kCameras);
double acc[3] = {0, 0, 0};
for (size_t i = 0; i < ck.size(); i++) {
if (!ck[i]) continue;
// OpenGL camera-to-world: the second column is the camera's up.
for (int r = 0; r < 3; r++) acc[r] += _ds.c2w[i * 12 + r * 4 + 1];
}
const spirula::Sim3 n = view_frame();
double v[3];
n.rotate(acc, v);
const double len = std::sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]);
if (!(len > 1e-9)) return false;
for (int r = 0; r < 3; r++) up[r] = (float)(v[r] / len);
return true;
}
bool PointsDoc::colours(std::vector<float>& rgb) const {
if (layer() != kPoints || _ds.points.rgb.empty()) return false;
rgb.resize(_ds.points.rgb.size());
for (size_t i = 0; i < rgb.size(); i++) rgb[i] = _ds.points.rgb[i] / 255.0f;
return true;
}
// Live cameras only, read straight off the camera layer.
std::vector<float> PointsDoc::camera_centres() const {
std::vector<float> out;
if (layer_count() <= kCameras) return out;
const std::vector<uint8_t>& ck = alive_of(kCameras);
const float* p = positions_of(kCameras);
for (size_t i = 0; i < ck.size(); i++)
if (ck[i]) out.insert(out.end(), p + i * 3, p + i * 3 + 3);
return out;
}
void PointsDoc::revert_display() {
if (_show) _show(_ds, _post, nullptr);
}
@@ -236,14 +286,19 @@ void PointsDoc::save(int target, const std::string& path,
for (size_t i = 0; i < ck.size(); i++)
if (!ck[i] && i < _ds.image_filenames.size())
keep.drop_images.push_back(_ds.image_filenames[i]);
spirula::sparse_write_filtered(path, keep);
const spirula::Sim3 moved = file_placement();
spirula::sparse_write_filtered(path, keep, &moved, &_baseline);
if (progress) (*progress)++;
return;
}
// A loose PLY is in the parsed frame, which the centring shift left.
spirula::Sim3 shift;
for (int i = 0; i < 3; i++) shift.t[i] = _ds.center[(size_t)i];
const spirula::Sim3 moved = file_placement() * shift;
spirula::write_ply_points(
path, _ds.points.xyz.data(),
_ds.points.rgb.empty() ? nullptr : _ds.points.rgb.data(),
_ds.points.num(), alive_of(kPoints).data());
_ds.points.num(), alive_of(kPoints).data(), &moved);
if (progress) (*progress)++;
}
+9
View File
@@ -35,6 +35,13 @@ public:
std::string default_save_path(int target) const override;
void revert_display() override;
bool live_centers(dsparse::CenterTable& out) const override;
spirula::Sim3 view_frame() const override;
bool up_hint(float up[3]) const override;
bool colours(std::vector<float>& rgb) const override;
bool colours_available() const override {
return layer() == 0 && !_ds.points.rgb.empty();
}
std::vector<float> camera_centres() const override;
spirula::SparseFormat format() const { return _fmt; }
@@ -56,6 +63,8 @@ private:
int64_t _live_cameras = -1; // what the display was baked for
std::string _dataset_dir;
spirula::SparseFormat _fmt = spirula::SparseFormat::None;
// The files as this session found them; every save filters these again.
spirula::SparseBaseline _baseline;
Show _show;
};
+4 -1
View File
@@ -162,6 +162,7 @@ void OcclusionBuffer::build(const EditDoc& doc, const ViewProjection& view) {
_W = std::max(1, (int)(view.W * _scale));
_H = std::max(1, (int)(view.H * _scale));
_z.assign((size_t)_W * _H, kFar);
_back = view.ortho_back;
const float* pos = doc.positions();
const float* rad = doc.radii();
@@ -195,7 +196,9 @@ bool OcclusionBuffer::visible(float px, float py, float depth, float tol) const
const int x = (int)(px * _scale), y = (int)(py * _scale);
if (x < 0 || y < 0 || x >= _W || y >= _H) return true;
const float z = _z[(size_t)y * _W + x];
return z >= kFar || depth <= z * (1.0f + tol) + 1e-6f;
// The slack is a share of the distance from the NAVIGATED eye, which in an
// orthographic view is a long way in front of the one that rendered.
return z >= kFar || depth <= z + tol * std::max(z - _back, 1e-6f) + 1e-6f;
}
+4
View File
@@ -27,6 +27,9 @@ struct ViewProjection {
int W = 1, H = 1;
// Camera position in the model frame, for the radius-to-pixels estimate.
float eye[3] = {0, 0, 0};
// An orthographic view is a pinhole pulled this far back (ViewportPanel::
// ortho_pullback). Depths include it; a RELATIVE depth test must not.
float ortho_back = 0.0f;
// Pixel and the distance in front of the camera. False where the model
// has no image for that direction.
@@ -83,6 +86,7 @@ public:
private:
int _W = 0, _H = 0;
float _back = 0.0f; // ViewProjection::ortho_back it was built for
float _scale = 1.0f; // view pixels -> buffer pixels
std::vector<float> _z;
};
+103 -2
View File
@@ -2,6 +2,7 @@
#include "app/gui/edit/SplatDoc.h"
#include "checkpoint/SplatTransform.h"
#include "engine/Engine.h"
#include "i18n/catalog/Edit.h"
@@ -34,7 +35,8 @@ TorchTensorView tv(std::vector<float>& v, std::vector<int64_t> shape) {
SplatDoc::SplatDoc(spirula::SplatCloud cloud, const std::string& source,
const float to_view[12], int slot, std::mutex* mu)
: _c(std::move(cloud)), _slot(slot), _mu(mu) {
: _c(std::move(cloud)), _to_view(spirula::Sim3::from_3x4(to_view)),
_slot(slot), _mu(mu) {
const int64_t n = _c.num;
const float scale = std::sqrt(to_view[0] * to_view[0] +
to_view[4] * to_view[4] +
@@ -54,8 +56,53 @@ SplatDoc::SplatDoc(spirula::SplatCloud cloud, const std::string& source,
}
_opacity.resize((size_t)n);
_dc.resize((size_t)n * 3);
_solid.resize((size_t)n);
for (int64_t i = 0; i < n; i++)
_solid[(size_t)i] = 1.0f / (1.0f + std::exp(-_c.opacities[(size_t)i]));
set_source(source);
add_layer(msg::elem_gaussian, n, std::move(pos), std::move(radius));
// Known only once the layer has measured itself: a Gaussian a twentieth
// of the scene across is sky or fog, whatever its opacity says.
const float big = 0.05f * extent();
const float* rad = radii();
for (int64_t i = 0; i < n; i++)
if (rad[i] > big) _solid[(size_t)i] = 0.0f;
}
// The render's own answer to "what is at this pixel" is where transmittance
// crosses one half, so the pick walks the ray the same way: front to back,
// each Gaussian taking its share, with the footprint taken as round.
int64_t SplatDoc::pick(const ViewProjection& view, float px, float py) const {
struct Hit { float depth, alpha; int64_t index; };
std::vector<Hit> hits;
const float* pos = positions();
const float* rad = radii();
const uint8_t* live = alive();
const int64_t n = count();
for (int64_t i = 0; i < n; i++) {
if (!live[i]) continue;
float ux, uy, d;
if (!view.project(pos + i * 3, ux, uy, d) || d <= 0.0f) continue;
const float r = std::max(rad[i] * view.fx / d, 0.5f);
const float dx = ux - px, dy = uy - py, q = (dx * dx + dy * dy) / (r * r);
if (q > 9.0f) continue;
const float op = 1.0f / (1.0f + std::exp(-_c.opacities[(size_t)i]));
const float a = op * std::exp(-0.5f * q);
if (a > 0.02f) hits.push_back({d, a, i});
}
if (hits.empty()) return -1;
std::sort(hits.begin(), hits.end(),
[](const Hit& a, const Hit& b) { return a.depth < b.depth; });
float T = 1.0f, best_w = 0.0f;
int64_t best = hits[0].index;
for (const Hit& h : hits) {
const float w = T * h.alpha;
if (w > best_w) { best_w = w; best = h.index; }
T *= 1.0f - std::min(h.alpha, 0.99f);
if (T < 0.5f) return h.index;
}
// Never half opaque: a thin spot. What contributed most is what is seen.
return best;
}
void SplatDoc::publish_impl(bool geometry) {
@@ -92,8 +139,62 @@ std::vector<SaveTarget> SplatDoc::save_targets() const {
std::string SplatDoc::default_save_path(int) const { return source_path(); }
void SplatDoc::save(int, const std::string& path, std::atomic<int>* progress) {
spirula::write_splat_ply(_c, path, alive());
// Means, orientation, scale AND the view-dependent colour bands.
const spirula::SplatTransform moved(file_placement(), _c.sh_degree);
spirula::write_splat_ply(_c, path, alive(), &moved);
if (progress) (*progress)++;
}
// The DC band back to a colour. A linear model goes through the sRGB curve,
// extended past 1 rather than clipped: a highlight at 4.0 is still brighter
// than one at 2.0, and a histogram that cannot tell is no use on an HDR model.
bool SplatDoc::colours(std::vector<float>& rgb) const {
const int64_t n = _c.num;
rgb.resize((size_t)n * 3);
const bool linear = _linear;
#pragma omp parallel for schedule(static)
for (int64_t i = 0; i < n * 3; i++) {
float v = 0.5f + kSh0 * _c.features_dc[(size_t)i];
if (linear) {
const float a = std::fabs(v);
const float e = a <= 0.0031308f ? 12.92f * a
: 1.055f * std::pow(a, 1.0f / 2.4f) - 0.055f;
v = v < 0 ? -e : e;
}
rgb[(size_t)i] = v;
}
return true;
}
// A Gaussian much thinner one way than the others is a piece of surface, and
// its thin axis is that surface's normal. The rest say nothing.
bool SplatDoc::normals(std::vector<float>& n, std::vector<float>& w) const {
const int64_t num = _c.num;
n.assign((size_t)num * 3, 0.0f);
w.assign((size_t)num, 0.0f);
#pragma omp parallel for schedule(static)
for (int64_t i = 0; i < num; i++) {
const float* sc = &_c.scales[(size_t)i * 3];
int lo = 0;
if (sc[1] < sc[lo]) lo = 1;
if (sc[2] < sc[lo]) lo = 2;
const float a = sc[(lo + 1) % 3], b = sc[(lo + 2) % 3];
// Log scales: thinner than a third of the smaller in-plane extent.
if (sc[lo] > std::min(a, b) - 1.1f) continue;
const float* q = &_c.quats[(size_t)i * 4];
float qn = std::sqrt(q[0]*q[0] + q[1]*q[1] + q[2]*q[2] + q[3]*q[3]);
if (!(qn > 1e-12f)) continue;
const float qw = q[0]/qn, x = q[1]/qn, y = q[2]/qn, z = q[3]/qn;
const float R[9] = {1-2*(y*y+z*z), 2*(x*y-z*qw), 2*(x*z+y*qw),
2*(x*y+z*qw), 1-2*(x*x+z*z), 2*(y*z-x*qw),
2*(x*z-y*qw), 2*(y*z+x*qw), 1-2*(x*x+y*y)};
for (int r = 0; r < 3; r++) n[(size_t)i * 3 + r] = R[r * 3 + lo];
const float op = 1.0f / (1.0f + std::exp(-_c.opacities[(size_t)i]));
// By linear size rather than area: one huge background splat is
// one opinion, not a thousand.
w[(size_t)i] = op * std::exp(0.5f * std::min(a + b, 16.0f));
}
return true;
}
} // namespace gui
+16
View File
@@ -9,6 +9,7 @@
// stroke. The cull that makes it free is the projection's ALPHA_THRESHOLD.
#include "app/gui/edit/EditDoc.h"
#include "app/gui/edit/SelectShape.h"
#include "checkpoint/SplatPly.h"
#include <mutex>
@@ -24,19 +25,34 @@ public:
const float to_view[12], int slot, std::mutex* mu);
Kind kind() const override { return Kind::Splats; }
// The Gaussian the eye lands on: the one at which the pixel's ray has
// lost half its light, not the haze floating in front of it.
int64_t pick(const ViewProjection& view, float px, float py) const override;
std::vector<SaveTarget> save_targets() const override;
void save(int target, const std::string& path,
std::atomic<int>* progress) override;
std::string default_save_path(int target) const override;
void revert_display() override;
spirula::Sim3 view_frame() const override { return _to_view; }
bool normals(std::vector<float>& n, std::vector<float>& w) const override;
bool colours(std::vector<float>& rgb) const override;
bool colours_available() const override { return true; }
const spirula::SplatCloud* splats() const override { return &_c; }
const float* solidity() const override { return _solid.data(); }
// Whether the file stores LINEAR colour, which the run's config says and
// the file does not; the colour attributes are display-referred.
void set_linear_colour(bool on) { _linear = on; }
protected:
void publish_impl(bool geometry) override;
private:
spirula::SplatCloud _c;
spirula::Sim3 _to_view;
bool _linear = false;
std::vector<float> _opacity; // upload scratch, alive-masked
std::vector<float> _dc; // upload scratch, selection-tinted
std::vector<float> _solid; // opacity, zeroed for the oversized
int _slot = -1;
std::mutex* _mu = nullptr;
};
+563
View File
@@ -0,0 +1,563 @@
// TransformTool.cpp -- see TransformTool.h.
#include "app/gui/edit/TransformTool.h"
#include "app/gui/Layout.h"
#include "imgui.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
namespace gui {
namespace {
constexpr double kPi = 3.14159265358979323846;
constexpr ImU32 kAxisCol[3] = {IM_COL32(250, 51, 79, 255),
IM_COL32(140, 219, 0, 255),
IM_COL32(41, 140, 250, 255)};
constexpr ImU32 kHot = IM_COL32(255, 235, 90, 255);
constexpr ImU32 kInk = IM_COL32(240, 240, 240, 235);
// Handle length on screen. Fixed in pixels, so the handles are the same size
// to grab whatever the zoom.
float handle_px() { return px(92.0f); }
double dot3(const double a[3], const double b[3]) {
return a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
}
bool project(const ViewProjection& cam, const double p[3], float& x, float& y) {
const float q[3] = {(float)p[0], (float)p[1], (float)p[2]};
float depth;
return cam.project(q, x, y, depth) && depth > 0.0f;
}
// Camera axes in the shared frame: the rows of its world-to-camera rotation.
void cam_axis(const ViewProjection& cam, int row, double out[3]) {
for (int k = 0; k < 3; k++) out[k] = cam.w2c[row * 4 + k];
}
// Pixels one shared unit spans at `p`, measured across the view.
double pixels_per_unit(const ViewProjection& cam, const double p[3]) {
double right[3];
cam_axis(cam, 0, right);
// A step small against the distance, so a wide lens's curvature and a
// pivot near the image edge do not bend the answer.
const double d[3] = {p[0] - cam.eye[0], p[1] - cam.eye[1], p[2] - cam.eye[2]};
const double h = std::max(std::sqrt(dot3(d, d)) * 1e-3, 1e-9);
const double q[3] = {p[0] + right[0]*h, p[1] + right[1]*h, p[2] + right[2]*h};
float x0, y0, x1, y1;
if (!project(cam, p, x0, y0) || !project(cam, q, x1, y1)) return 0.0;
return std::hypot((double)(x1 - x0), (double)(y1 - y0)) / h;
}
bool ray_plane(const ViewProjection& cam, float mx, float my,
const double point[3], const double normal[3], double out[3]) {
float o[3], d[3];
if (!cam.unproject(mx, my, o, d)) return false;
const double dd[3] = {d[0], d[1], d[2]};
const double denom = dot3(dd, normal);
if (std::fabs(denom) < 0.02) return false;
const double w[3] = {point[0] - o[0], point[1] - o[1], point[2] - o[2]};
const double t = dot3(w, normal) / denom;
if (!(t > 0.0)) return false;
for (int k = 0; k < 3; k++) out[k] = o[k] + t * dd[k];
return true;
}
double seg_distance(float px_, float py_, float ax, float ay, float bx, float by) {
const double vx = bx - ax, vy = by - ay, wx = px_ - ax, wy = py_ - ay;
const double l2 = vx*vx + vy*vy;
double t = l2 > 1e-12 ? (wx*vx + wy*vy) / l2 : 0.0;
t = std::clamp(t, 0.0, 1.0);
return std::hypot(wx - t*vx, wy - t*vy);
}
// Two unit vectors square to `a` and to each other.
void ring_basis(const double a[3], double u[3], double v[3]) {
double other[3] = {1, 0, 0};
if (std::fabs(a[0]) > 0.8) { other[0] = 0; other[1] = 1; }
u[0] = a[1]*other[2] - a[2]*other[1];
u[1] = a[2]*other[0] - a[0]*other[2];
u[2] = a[0]*other[1] - a[1]*other[0];
const double l = std::sqrt(dot3(u, u));
for (int k = 0; k < 3; k++) u[k] /= l;
v[0] = a[1]*u[2] - a[2]*u[1];
v[1] = a[2]*u[0] - a[0]*u[2];
v[2] = a[0]*u[1] - a[1]*u[0];
}
double snap_to(double v, double step) {
return step > 0 ? std::round(v / step) * step : v;
}
} // namespace
const double* TransformTool::axis_dir(const XformFrame& f, int a) const {
return (_space == Space::Local ? f.local_axes : f.global_axes) + a * 3;
}
void TransformTool::begin(XformKind kind, const XformFrame& f, float mx, float my,
bool drag, int axis, bool plane) {
_active = true;
_drag = drag;
_kind = kind;
_axis = kind == XformKind::Scale ? -1 : axis;
_plane = plane && _axis >= 0;
_space = Space::Global;
_start[0] = _mouse[0] = _last_real[0] = mx;
_start[1] = _mouse[1] = _last_real[1] = my;
_angle = 0.0;
float cx = f.cam.cx, cy = f.cam.cy;
project(f.cam, f.pivot, cx, cy);
_last_angle = std::atan2((double)(my - cy), (double)(mx - cx));
_typed.clear();
_delta = spirula::Sim3();
_value[0] = _value[1] = _value[2] = 0.0;
}
void TransformTool::handle_keys(const XformFrame& f) {
(void)f;
const ImGuiIO& io = ImGui::GetIO();
if (io.WantTextInput) return;
auto restart = [&](XformKind k) {
if (k == _kind) return;
_kind = k;
_axis = -1;
_plane = false;
_typed.clear();
_start[0] = _mouse[0];
_start[1] = _mouse[1];
_angle = 0.0;
};
if (ImGui::IsKeyPressed(ImGuiKey_G, false)) restart(XformKind::Move);
if (ImGui::IsKeyPressed(ImGuiKey_R, false)) restart(XformKind::Rotate);
if (ImGui::IsKeyPressed(ImGuiKey_S, false)) restart(XformKind::Scale);
// X, then X again for the model's own X, then X again to let go: the
// cycle every modal transform has. Shift+X is the plane across X.
const ImGuiKey axis_keys[3] = {ImGuiKey_X, ImGuiKey_Y, ImGuiKey_Z};
for (int a = 0; a < 3 && _kind != XformKind::Scale; a++) {
if (!ImGui::IsKeyPressed(axis_keys[a], false)) continue;
const bool plane = io.KeyShift && _kind == XformKind::Move;
if (_axis == a && _plane == plane) {
if (_space == Space::Global) _space = Space::Local;
else { _axis = -1; _plane = false; _space = Space::Global; }
} else {
_axis = a;
_plane = plane;
_space = Space::Global;
}
}
for (int d = 0; d < 10; d++)
if (ImGui::IsKeyPressed((ImGuiKey)(ImGuiKey_0 + d), false) ||
ImGui::IsKeyPressed((ImGuiKey)(ImGuiKey_Keypad0 + d), false))
_typed.push_back((char)('0' + d));
if ((ImGui::IsKeyPressed(ImGuiKey_Period, false) ||
ImGui::IsKeyPressed(ImGuiKey_KeypadDecimal, false)) &&
_typed.find('.') == std::string::npos)
_typed.push_back('.');
if (ImGui::IsKeyPressed(ImGuiKey_Minus, false) ||
ImGui::IsKeyPressed(ImGuiKey_KeypadSubtract, false)) {
if (!_typed.empty() && _typed[0] == '-') _typed.erase(0, 1);
else _typed.insert(_typed.begin(), '-');
}
if (ImGui::IsKeyPressed(ImGuiKey_Backspace, true) && !_typed.empty())
_typed.pop_back();
}
TransformTool::Result TransformTool::update(const ViewportInput& in,
const XformFrame& f) {
if (!_active) return Result::Idle;
handle_keys(f);
_fine = in.shift;
_snap = in.ctrl;
// Shift slows the pointer tenfold FROM WHERE IT IS, which is why the
// operator follows an accumulated position and not the pointer itself.
const float k = _fine ? 0.1f : 1.0f;
_mouse[0] += (in.x - _last_real[0]) * k;
_mouse[1] += (in.y - _last_real[1]) * k;
_last_real[0] = in.x;
_last_real[1] = in.y;
float cx = f.cam.cx, cy = f.cam.cy;
project(f.cam, f.pivot, cx, cy);
const double a = std::atan2((double)(_mouse[1] - cy), (double)(_mouse[0] - cx));
_angle += std::remainder(a - _last_angle, 2.0 * kPi);
_last_angle = a;
recompute(f);
const bool enter = ImGui::IsKeyPressed(ImGuiKey_Enter, false) ||
ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false);
if (ImGui::IsKeyPressed(ImGuiKey_Escape, false) || in.right_clicked) {
_active = false;
return Result::Cancelled;
}
if (enter || (_drag ? in.released : (in.clicked && in.hovered))) {
_active = false;
return Result::Confirmed;
}
return Result::Running;
}
void TransformTool::recompute(const XformFrame& f) {
const bool typed = !_typed.empty() && _typed != "-" && _typed != "." &&
_typed != "-.";
const double number = typed ? std::atof(_typed.c_str()) : 0.0;
const double unit = f.unit > 0 ? f.unit : 1.0;
_value[0] = _value[1] = _value[2] = 0.0;
_delta = spirula::Sim3();
if (_kind == XformKind::Move) {
const double step = f.grid_cell * (_fine ? 0.1 : 1.0);
double move[3] = {0, 0, 0};
if (_axis >= 0 && !_plane) {
const double* ax = axis_dir(f, _axis);
double t = 0.0;
if (typed) {
t = number * unit;
} else {
// The pointer's travel measured along the axis AS DRAWN: it
// stays well behaved when the axis points nearly at the eye,
// where intersecting rays with it does not.
const double pps = pixels_per_unit(f.cam, f.pivot);
const double h = pps > 0 ? 40.0 / pps : 0.0;
const double q[3] = {f.pivot[0] + ax[0]*h, f.pivot[1] + ax[1]*h,
f.pivot[2] + ax[2]*h};
float x0, y0, x1, y1;
if (h > 0 && project(f.cam, f.pivot, x0, y0) &&
project(f.cam, q, x1, y1)) {
const double vx = (x1 - x0) / h, vy = (y1 - y0) / h;
const double l2 = vx*vx + vy*vy;
if (l2 > 1e-6 * pps * pps)
t = ((_mouse[0] - _start[0]) * vx +
(_mouse[1] - _start[1]) * vy) / l2;
}
if (_snap) t = snap_to(t / unit, step) * unit;
}
for (int k = 0; k < 3; k++) move[k] = ax[k] * t;
_value[_axis] = t / unit;
} else {
double normal[3];
if (_plane) for (int k = 0; k < 3; k++) normal[k] = axis_dir(f, _axis)[k];
else cam_axis(f.cam, 2, normal);
double p0[3], p1[3];
bool ok = ray_plane(f.cam, _start[0], _start[1], f.pivot, normal, p0) &&
ray_plane(f.cam, _mouse[0], _mouse[1], f.pivot, normal, p1);
if (!ok && _plane) {
// The plane is edge-on: slide in the view plane instead and
// keep only what lies in the constraint.
cam_axis(f.cam, 2, normal);
ok = ray_plane(f.cam, _start[0], _start[1], f.pivot, normal, p0) &&
ray_plane(f.cam, _mouse[0], _mouse[1], f.pivot, normal, p1);
}
if (ok) for (int k = 0; k < 3; k++) move[k] = p1[k] - p0[k];
// Into components along the axes, for the snap, the constraint
// and the readout alike.
double comp[3];
for (int i = 0; i < 3; i++) comp[i] = dot3(move, axis_dir(f, i)) / unit;
if (_plane) comp[_axis] = 0.0;
if (typed) {
comp[0] = comp[1] = comp[2] = 0.0;
comp[_plane && _axis == 0 ? 1 : 0] = number;
} else if (_snap) {
for (double& c : comp) c = snap_to(c, step);
}
for (int k = 0; k < 3; k++) {
move[k] = 0.0;
for (int i = 0; i < 3; i++) move[k] += axis_dir(f, i)[k] * comp[i] * unit;
}
for (int i = 0; i < 3; i++) _value[i] = comp[i];
}
_delta = spirula::Sim3::translation(move);
return;
}
if (_kind == XformKind::Rotate) {
double fwd[3], axis[3];
cam_axis(f.cam, 2, fwd);
double angle = _angle;
if (_axis >= 0) {
for (int k = 0; k < 3; k++) axis[k] = axis_dir(f, _axis)[k];
// On screen the pointer turns about the view axis; about another
// one that is the same turn or its mirror image.
if (dot3(axis, fwd) < 0) angle = -angle;
} else {
for (int k = 0; k < 3; k++) axis[k] = fwd[k];
}
if (typed) {
// Typed degrees are about the axis itself, right-handed; with no
// axis, counter-clockwise as seen.
angle = number * kPi / 180.0;
if (_axis < 0) angle = -angle;
} else if (_snap) {
angle = snap_to(angle, (_fine ? 1.0 : 5.0) * kPi / 180.0);
}
_value[0] = (_axis >= 0 ? angle : -angle) * 180.0 / kPi;
_delta = spirula::Sim3::rotation_about(axis, angle, f.pivot);
return;
}
float cx = f.cam.cx, cy = f.cam.cy;
project(f.cam, f.pivot, cx, cy);
const double r0 = std::max(std::hypot((double)(_start[0] - cx),
(double)(_start[1] - cy)), 12.0);
double factor = std::hypot((double)(_mouse[0] - cx), (double)(_mouse[1] - cy)) / r0;
if (typed && number > 0) factor = number;
else if (_snap) factor = snap_to(factor, _fine ? 0.01 : 0.1);
factor = std::clamp(factor, 1e-4, 1e4);
_value[0] = factor;
_delta = spirula::Sim3::scale_about(factor, f.pivot);
}
std::string TransformTool::readout(const XformFrame& f) const {
(void)f;
char buf[160];
const char* names = "XYZ";
const char* local = _space == Space::Local ? "'" : "";
switch (_kind) {
case XformKind::Move:
if (_axis >= 0 && !_plane)
std::snprintf(buf, sizeof buf, "%c%s %.4g", names[_axis], local,
_value[_axis]);
else
std::snprintf(buf, sizeof buf, "%.4g, %.4g, %.4g", _value[0],
_value[1], _value[2]);
break;
case XformKind::Rotate:
if (_axis >= 0)
std::snprintf(buf, sizeof buf, "%c%s %.2f\xc2\xb0", names[_axis],
local, _value[0]);
else
std::snprintf(buf, sizeof buf, "%.2f\xc2\xb0", _value[0]);
break;
default:
std::snprintf(buf, sizeof buf, "\xc3\x97 %.4g", _value[0]);
}
std::string s = buf;
if (!_typed.empty()) s += " [" + _typed + "]";
return s;
}
// ---------------------------------------------------------------------------
// Handles
// ---------------------------------------------------------------------------
int TransformTool::hit_handle(XformKind mode, const XformFrame& f, float mx,
float my) const {
float cx, cy;
if (!project(f.cam, f.pivot, cx, cy)) return -1;
const double pps = pixels_per_unit(f.cam, f.pivot);
if (!(pps > 0)) return -1;
const double L = handle_px() / pps;
const float grab = px(8.0f);
const double from_centre = std::hypot((double)(mx - cx), (double)(my - cy));
if (mode == XformKind::Scale)
return std::fabs(from_centre - handle_px()) < grab || from_centre < px(10.0f)
? 6 : -1;
if (mode == XformKind::Move) {
if (from_centre < px(10.0f)) return 6;
for (int a = 0; a < 3; a++) {
const double* u = f.global_axes + ((a + 1) % 3) * 3;
const double* v = f.global_axes + ((a + 2) % 3) * 3;
const double q[3] = {f.pivot[0] + (u[0] + v[0]) * L * 0.36,
f.pivot[1] + (u[1] + v[1]) * L * 0.36,
f.pivot[2] + (u[2] + v[2]) * L * 0.36};
float x, y;
if (project(f.cam, q, x, y) &&
std::hypot((double)(mx - x), (double)(my - y)) < px(9.0f))
return 3 + a;
}
int best = -1;
double best_d = grab;
for (int a = 0; a < 3; a++) {
const double* ax = f.global_axes + a * 3;
const double q[3] = {f.pivot[0] + ax[0]*L, f.pivot[1] + ax[1]*L,
f.pivot[2] + ax[2]*L};
float x, y;
if (!project(f.cam, q, x, y)) continue;
const double d = seg_distance(mx, my, cx, cy, x, y);
if (d < best_d) { best_d = d; best = a; }
}
return best;
}
// Rotate: the three rings, then the ring that turns about the view.
int best = -1;
double best_d = grab;
for (int a = 0; a < 3; a++) {
double u[3], v[3];
ring_basis(f.global_axes + a * 3, u, v);
float lx = 0, ly = 0;
bool have = false;
for (int s = 0; s <= 48; s++) {
const double t = 2.0 * kPi * s / 48.0;
const double q[3] = {f.pivot[0] + (u[0]*std::cos(t) + v[0]*std::sin(t)) * L,
f.pivot[1] + (u[1]*std::cos(t) + v[1]*std::sin(t)) * L,
f.pivot[2] + (u[2]*std::cos(t) + v[2]*std::sin(t)) * L};
float x, y;
if (!project(f.cam, q, x, y)) { have = false; continue; }
if (have) {
const double d = seg_distance(mx, my, lx, ly, x, y);
if (d < best_d) { best_d = d; best = a; }
}
lx = x; ly = y; have = true;
}
}
if (best >= 0) return best;
return std::fabs(from_centre - handle_px() * 1.18) < grab ? 6 : -1;
}
void TransformTool::draw_handles(ImDrawList* dl, const ImVec2& origin,
XformKind mode, const XformFrame& f,
int hot) const {
float cx, cy;
if (!project(f.cam, f.pivot, cx, cy)) return;
const double pps = pixels_per_unit(f.cam, f.pivot);
if (!(pps > 0)) return;
const double L = handle_px() / pps;
const ImVec2 c(origin.x + cx, origin.y + cy);
auto at = [&](const double q[3], ImVec2& out) {
float x, y;
if (!project(f.cam, q, x, y)) return false;
out = ImVec2(origin.x + x, origin.y + y);
return true;
};
if (mode == XformKind::Move) {
for (int a = 0; a < 3; a++) {
const double* u = f.global_axes + ((a + 1) % 3) * 3;
const double* v = f.global_axes + ((a + 2) % 3) * 3;
ImVec2 q[4];
bool ok = true;
const double k0 = 0.26, k1 = 0.46;
const double corners[4][2] = {{k0, k0}, {k1, k0}, {k1, k1}, {k0, k1}};
for (int i = 0; i < 4 && ok; i++) {
const double p[3] = {
f.pivot[0] + (u[0]*corners[i][0] + v[0]*corners[i][1]) * L,
f.pivot[1] + (u[1]*corners[i][0] + v[1]*corners[i][1]) * L,
f.pivot[2] + (u[2]*corners[i][0] + v[2]*corners[i][1]) * L};
ok = at(p, q[i]);
}
if (!ok) continue;
const ImU32 col = hot == 3 + a ? kHot : kAxisCol[a];
dl->AddConvexPolyFilled(q, 4, (col & 0x00ffffff) | 0x60000000);
dl->AddPolyline(q, 4, col, ImDrawFlags_Closed, px(1.5f));
}
for (int a = 0; a < 3; a++) {
const double* ax = f.global_axes + a * 3;
const double tip[3] = {f.pivot[0] + ax[0]*L, f.pivot[1] + ax[1]*L,
f.pivot[2] + ax[2]*L};
ImVec2 e;
if (!at(tip, e)) continue;
const ImU32 col = hot == a ? kHot : kAxisCol[a];
dl->AddLine(c, e, col, px(hot == a ? 3.5f : 2.5f));
const float dx = e.x - c.x, dy = e.y - c.y;
const float l = std::max(std::hypot(dx, dy), 1e-3f);
const float ux = dx / l, uy = dy / l, h = px(11.0f), w = px(5.0f);
dl->AddTriangleFilled(ImVec2(e.x + ux * h, e.y + uy * h),
ImVec2(e.x - uy * w, e.y + ux * w),
ImVec2(e.x + uy * w, e.y - ux * w), col);
}
dl->AddCircleFilled(c, px(6.0f), hot == 6 ? kHot : kInk, 20);
return;
}
if (mode == XformKind::Rotate) {
double fwd[3];
cam_axis(f.cam, 2, fwd);
for (int a = 0; a < 3; a++) {
double u[3], v[3];
ring_basis(f.global_axes + a * 3, u, v);
const ImU32 col = hot == a ? kHot : kAxisCol[a];
ImVec2 last;
bool have = false;
for (int s = 0; s <= 64; s++) {
const double t = 2.0 * kPi * s / 64.0;
const double d[3] = {u[0]*std::cos(t) + v[0]*std::sin(t),
u[1]*std::cos(t) + v[1]*std::sin(t),
u[2]*std::cos(t) + v[2]*std::sin(t)};
const double q[3] = {f.pivot[0] + d[0]*L, f.pivot[1] + d[1]*L,
f.pivot[2] + d[2]*L};
ImVec2 e;
if (!at(q, e)) { have = false; continue; }
// The half of the ring behind the pivot is drawn faint: it
// is what makes three ellipses read as a sphere.
const bool back = dot3(d, fwd) > 0.05;
if (have)
dl->AddLine(last, e, back ? (col & 0x00ffffff) | 0x50000000 : col,
px(hot == a ? 3.0f : 2.0f));
last = e;
have = true;
}
}
dl->AddCircle(c, handle_px() * 1.18f, hot == 6 ? kHot : kInk, 64, px(1.5f));
dl->AddCircleFilled(c, px(3.0f), kInk, 12);
return;
}
dl->AddCircle(c, handle_px(), hot == 6 ? kHot : kInk, 64, px(hot == 6 ? 3.0f : 2.0f));
for (int a = 0; a < 3; a++) {
const double* ax = f.global_axes + a * 3;
const double tip[3] = {f.pivot[0] + ax[0]*L*0.7, f.pivot[1] + ax[1]*L*0.7,
f.pivot[2] + ax[2]*L*0.7};
ImVec2 e;
if (!at(tip, e)) continue;
dl->AddLine(c, e, kAxisCol[a], px(2.0f));
dl->AddRectFilled(ImVec2(e.x - px(4.0f), e.y - px(4.0f)),
ImVec2(e.x + px(4.0f), e.y + px(4.0f)), kAxisCol[a]);
}
dl->AddRectFilled(ImVec2(c.x - px(5.0f), c.y - px(5.0f)),
ImVec2(c.x + px(5.0f), c.y + px(5.0f)), hot == 6 ? kHot : kInk);
}
void TransformTool::draw_overlay(ImDrawList* dl, const ImVec2& origin,
const XformFrame& f) const {
if (!_active) return;
float cx, cy;
const bool have_pivot = project(f.cam, f.pivot, cx, cy);
const ImVec2 c(origin.x + cx, origin.y + cy);
// The constraint, drawn right across the view: a short stub would say
// which axis but not where it goes.
if (_axis >= 0 && have_pivot) {
const double pps = pixels_per_unit(f.cam, f.pivot);
const double reach = pps > 0 ? 4000.0 / pps : 0.0;
for (int a = 0; a < 3; a++) {
const bool drawn = _plane ? a != _axis : a == _axis;
if (!drawn) continue;
const double* ax = axis_dir(f, a);
ImVec2 last;
bool have = false;
for (int s = -24; s <= 24; s++) {
const double t = reach * s / 24.0;
const double q[3] = {f.pivot[0] + ax[0]*t, f.pivot[1] + ax[1]*t,
f.pivot[2] + ax[2]*t};
float x, y;
if (!project(f.cam, q, x, y)) { have = false; continue; }
const ImVec2 e(origin.x + x, origin.y + y);
if (have) dl->AddLine(last, e, kAxisCol[a], px(1.5f));
last = e;
have = true;
}
}
}
if (have_pivot) {
const ImVec2 m(origin.x + _mouse[0], origin.y + _mouse[1]);
if (_kind != XformKind::Move)
dl->AddLine(c, m, IM_COL32(255, 255, 255, 120), px(1.0f));
dl->AddCircleFilled(c, px(4.0f), kHot, 16);
dl->AddCircle(c, px(4.0f), IM_COL32(0, 0, 0, 200), 16, px(1.0f));
}
}
} // namespace gui
+90
View File
@@ -0,0 +1,90 @@
#pragma once
// Placing a model: the modal operator (G / R / S, then X / Y / Z, typed
// numbers, Shift for precision, Ctrl to snap) and the handles drawn at the
// pivot for anyone who has not learned those keys. A handle drag IS the
// operator, started with its constraint already chosen and confirmed by
// letting go -- one code path, two ways in.
//
// Everything here happens in the SHARED frame the camera navigates: the
// model moves through it and the grid stands still. What comes out is a
// similarity of that frame; turning it into the document's placement is the
// session's business. Design: docs/notes/scene-transform.md.
#include "app/gui/ViewportInput.h"
#include "app/gui/edit/SelectShape.h"
#include "core/Similarity.h"
#include <string>
struct ImVec2;
namespace gui {
enum class XformKind { Move = 0, Rotate, Scale };
// What the operator is given each frame it runs.
struct XformFrame {
ViewProjection cam; // the shared-frame camera, at the image's size
double pivot[3] = {0, 0, 0}; // shared frame
// The axes a constraint means, as rows, shared frame: the saved file's
// own for "global", the model's as it now sits for "local".
double global_axes[9] = {1,0,0, 0,1,0, 0,0,1};
double local_axes[9] = {1,0,0, 0,1,0, 0,0,1};
// Shared units per file unit, so a typed "2" is two of what gets saved.
double unit = 1.0;
// One grid cell, in file units: what Ctrl snaps a move to.
double grid_cell = 1.0;
};
class TransformTool {
public:
enum class Result { Idle, Running, Confirmed, Cancelled };
enum class Space { Global = 0, Local };
bool active() const { return _active; }
XformKind kind() const { return _kind; }
// `drag` is a handle drag: releasing the button confirms. `axis` 0..2
// constrains to it, or to the plane across it when `plane`.
void begin(XformKind kind, const XformFrame& f, float mx, float my,
bool drag, int axis = -1, bool plane = false);
void cancel() { _active = false; }
// One frame of pointer and keys. Confirmed / Cancelled are returned once.
Result update(const ViewportInput& in, const XformFrame& f);
// The step so far, in the shared frame.
const spirula::Sim3& delta() const { return _delta; }
// The handles, when nothing is running: which one is under the pointer
// (0..2 an axis, 3..5 the plane across it, 6 the free / view handle, -1).
int hit_handle(XformKind mode, const XformFrame& f, float mx, float my) const;
void draw_handles(ImDrawList* dl, const ImVec2& origin, XformKind mode,
const XformFrame& f, int hot) const;
// The running operator: its axis, its pivot, and what it has done so far.
void draw_overlay(ImDrawList* dl, const ImVec2& origin, const XformFrame& f) const;
// "Move X: 1.25", already formatted: numbers and axis letters only.
std::string readout(const XformFrame& f) const;
private:
void recompute(const XformFrame& f);
void handle_keys(const XformFrame& f);
const double* axis_dir(const XformFrame& f, int a) const;
bool _active = false;
bool _drag = false;
XformKind _kind = XformKind::Move;
int _axis = -1; // -1 free
bool _plane = false;
Space _space = Space::Global;
float _start[2] = {0, 0};
float _mouse[2] = {0, 0}; // precision-scaled, not the real pointer
float _last_real[2] = {0, 0};
double _angle = 0.0; // accumulated, so a drag can pass 180
double _last_angle = 0.0;
bool _snap = false, _fine = false;
std::string _typed;
double _value[3] = {0, 0, 0}; // what readout() prints
spirula::Sim3 _delta;
};
} // namespace gui
+85
View File
@@ -0,0 +1,85 @@
// WorldGrid.cpp -- see WorldGrid.h.
#include "app/gui/edit/WorldGrid.h"
#include "app/gui/Layout.h"
#include "imgui.h"
#include <algorithm>
#include <cmath>
namespace gui {
namespace {
// Cells either side of the focus. Every cell edge is its own segment, so a
// fisheye view bends the lines and a line crossing behind the camera loses
// only the part that did: 2 * 41 * 40 segments a frame.
constexpr int kHalf = 20;
struct Pen {
ImDrawList* dl;
const ViewProjection* cam;
const spirula::Sim3* to_shared;
ImVec2 origin;
bool at(const double p[3], ImVec2& out) const {
double q[3];
to_shared->apply(p, q);
const float f[3] = {(float)q[0], (float)q[1], (float)q[2]};
float x, y, depth;
if (!cam->project(f, x, y, depth) || !(depth > 0.0f)) return false;
// A point far outside the image is a line ImGui would have to clip
// across half a million pixels; nothing on screen needs it.
if (std::fabs(x) > 1e5f || std::fabs(y) > 1e5f) return false;
out = ImVec2(origin.x + x, origin.y + y);
return true;
}
void line(const double a[3], const double b[3], ImU32 col, float w) const {
ImVec2 pa, pb;
if (at(a, pa) && at(b, pb)) dl->AddLine(pa, pb, col, w);
}
};
} // namespace
void draw_world_grid(ImDrawList* dl, const ImVec2& origin,
const ViewProjection& cam, const spirula::Sim3& to_shared,
double cell, const double focus[3]) {
if (!(cell > 0.0)) return;
const Pen pen{dl, &cam, &to_shared, origin};
double f[3];
to_shared.inverse().apply(focus, f);
const long gx = std::lround(f[0] / cell), gy = std::lround(f[1] / cell);
const float thin = px(1.0f);
for (int i = -kHalf; i <= kHalf; i++) {
const bool major_x = (gx + i) % 10 == 0, major_y = (gy + i) % 10 == 0;
for (int j = -kHalf; j < kHalf; j++) {
// Faded toward the edge of the patch, so it has no edge.
const double r = std::max(std::abs(i), std::max(std::abs(j), std::abs(j + 1)));
const int alpha = (int)(std::clamp(1.0 - r / kHalf, 0.0, 1.0) * 150.0);
if (alpha < 8) continue;
const double x = (gx + i) * cell, y = (gy + i) * cell;
const double a0 = (gy + j) * cell, a1 = (gy + j + 1) * cell;
const double b0 = (gx + j) * cell, b1 = (gx + j + 1) * cell;
const double pa[3] = {x, a0, 0}, pb[3] = {x, a1, 0};
const double qa[3] = {b0, y, 0}, qb[3] = {b1, y, 0};
pen.line(pa, pb, IM_COL32(200, 205, 215, major_x ? alpha : alpha / 2), thin);
pen.line(qa, qb, IM_COL32(200, 205, 215, major_y ? alpha : alpha / 2), thin);
}
}
const ImU32 axis_col[3] = {IM_COL32(250, 51, 79, 230), IM_COL32(140, 219, 0, 230),
IM_COL32(41, 140, 250, 230)};
for (int a = 0; a < 3; a++)
for (int j = 0; j < kHalf; j++) {
double p0[3] = {0, 0, 0}, p1[3] = {0, 0, 0};
p0[a] = j * cell;
p1[a] = (j + 1) * cell;
pen.line(p0, p1, axis_col[a], px(2.0f));
}
}
} // namespace gui
+26
View File
@@ -0,0 +1,26 @@
#pragma once
// The ground grid and axes, drawn over the image in a frame that STANDS
// STILL while a model is placed against it.
//
// The renderers draw their grid in the model's own coordinates, which is the
// right thing until the model is what is moving. This one is drawn in the
// coordinates the model will be SAVED in -- the file frame with the placement
// applied -- so a floor lying on it is a floor at z = 0 in the file.
#include "app/gui/edit/SelectShape.h"
#include "core/Similarity.h"
struct ImDrawList;
struct ImVec2;
namespace gui {
// `to_shared` takes those saved coordinates into the frame `cam` looks at,
// `cell` is one grid cell in them, and `focus` (shared frame) is what the
// patch of lines is centred under.
void draw_world_grid(ImDrawList* dl, const ImVec2& origin,
const ViewProjection& cam, const spirula::Sim3& to_shared,
double cell, const double focus[3]);
} // namespace gui
+153
View File
@@ -0,0 +1,153 @@
// align_fit_test -- app/gui/edit/AlignFit.h over a room whose answer is known:
// a floor, two walls and a box standing on the floor, with noise and floaters,
// tipped over by a rotation the fit then has to find its way back from.
#include "app/gui/edit/AlignFit.h"
#include <cmath>
#include <cstdio>
#include <random>
#include <string>
#include <vector>
using spirula::Sim3;
namespace al = gui::align;
namespace {
int g_failures = 0;
void check(bool ok, const std::string& what) {
std::printf("%s %s\n", ok ? "ok " : "FAIL", what.c_str());
if (!ok) g_failures++;
}
double deg(double rad) { return rad * 180.0 / 3.14159265358979; }
} // namespace
int main() {
std::mt19937 rng(3);
std::uniform_real_distribution<double> u(0.0, 1.0);
std::normal_distribution<double> g(0.0, 1.0);
const double noise = 0.004;
// The room, in the frame it SHOULD end up in: floor z = 0, walls x = 0
// and y = 0, 4 x 3 x 2.5 units, and a box on the floor.
std::vector<double> room;
auto add = [&](double x, double y, double z) {
room.insert(room.end(), {x + noise * g(rng), y + noise * g(rng),
z + noise * g(rng)});
};
for (int i = 0; i < 9000; i++) add(4 * u(rng), 3 * u(rng), 0);
for (int i = 0; i < 5000; i++) add(0, 3 * u(rng), 2.5 * u(rng));
for (int i = 0; i < 4000; i++) add(4 * u(rng), 0, 2.5 * u(rng));
for (int i = 0; i < 1500; i++) add(1.5 + 0.5 * u(rng), 1.2 + 0.5 * u(rng), 0.6);
for (int i = 0; i < 1500; i++) add(1.5, 1.2 + 0.5 * u(rng), 0.6 * u(rng));
for (int i = 0; i < 600; i++) // floaters
room.insert(room.end(), {8 * u(rng) - 2, 8 * u(rng) - 2, 6 * u(rng) - 1});
const int64_t n = (int64_t)room.size() / 3;
// Tipped over and carried off.
const double axis[3] = {0.6, -0.64, 0.48}, origin[3] = {0, 0, 0};
Sim3 tip = Sim3::rotation_about(axis, 0.7, origin);
tip.t[0] = 3.0; tip.t[1] = -2.0; tip.t[2] = 1.5;
std::vector<double> pts(room.size());
for (int64_t i = 0; i < n; i++) tip.apply(&room[i*3], &pts[i*3]);
double up_tipped[3];
const double zaxis[3] = {0, 0, 1};
tip.rotate(zaxis, up_tipped);
// ---- one plane ----
{
al::Plane pl;
const bool ok = al::fit_plane(pts.data(), n, 0.02, 1u, pl);
const double c = std::fabs(pl.n[0]*up_tipped[0] + pl.n[1]*up_tipped[1] +
pl.n[2]*up_tipped[2]);
check(ok && deg(std::acos(std::min(1.0, c))) < 0.3,
"the largest plane is the floor, to a third of a degree");
}
// ---- auto align, with the camera-style up prior 25 degrees off ----
{
const double lean[3] = {0.0, 1.0, 0.0};
Sim3 off = Sim3::rotation_about(lean, 0.43, origin);
double prior[3];
off.rotate(up_tipped, prior);
al::AutoAlignOptions opt;
opt.tol = 0.02;
const al::AutoAlignResult r =
al::auto_align(pts.data(), n, prior, nullptr, nullptr, opt);
check(r.ground, "auto align: a ground was found");
check(r.walls, "auto align: the walls were found");
// r.T * tip should be the identity up to quarter turns about z and a
// shift in x, y.
const Sim3 net = r.T * tip;
check(deg(std::acos(std::min(1.0, net.R[8]))) < 0.3, "auto align: +Z is up");
const double yaw = deg(std::atan2(net.R[3], net.R[0]));
const double folded = std::fabs(std::remainder(yaw, 90.0));
std::printf(" residual yaw %.3f deg, floor height %.4f\n", folded, net.t[2]);
check(folded < 0.5, "auto align: walls on the axes");
check(std::fabs(net.t[2]) < 0.01, "auto align: floor at z = 0");
}
// ---- a click on the floor beside the box ----
{
const double click_room[3] = {1.2, 1.4, 0.0};
double click[3];
tip.apply(click_room, click);
al::Plane pl;
const bool ok = al::fit_plane_at(pts.data(), n, click, 0.15, pl);
const double c = std::fabs(pl.n[0]*up_tipped[0] + pl.n[1]*up_tipped[1] +
pl.n[2]*up_tipped[2]);
std::printf(" click fit: %.3f deg off, %lld points\n",
deg(std::acos(std::min(1.0, c))), (long long)pl.inliers);
check(ok && deg(std::acos(std::min(1.0, c))) < 0.3,
"a click on the floor finds the floor, not the box beside it");
check(pl.inliers > 2000, "... and grows well past the first patch");
}
// ---- the room's corner ----
{
const double corner_room[3] = {0.05, 0.08, 0.04};
double click[3], axes[9], corner[3];
tip.apply(corner_room, click);
const int m = al::fit_corner(pts.data(), n, click, 0.4, axes, corner);
check(m == 3, "three surfaces meet at the corner");
double want[3];
tip.apply(origin, want);
const double miss = std::sqrt((corner[0]-want[0])*(corner[0]-want[0]) +
(corner[1]-want[1])*(corner[1]-want[1]) +
(corner[2]-want[2])*(corner[2]-want[2]));
std::printf(" corner found %.4f from where it is\n", miss);
check(miss < 0.02, "... and the corner point is where they meet");
// Every found axis is one of the room's, either way round.
double worst = 0;
for (int r = 0; r < 3; r++) {
double best = 0;
for (int k = 0; k < 3; k++) {
double e[3] = {0, 0, 0}, w[3];
e[k] = 1;
tip.rotate(e, w);
best = std::max(best, std::fabs(axes[r*3]*w[0] + axes[r*3+1]*w[1] +
axes[r*3+2]*w[2]));
}
worst = std::max(worst, deg(std::acos(std::min(1.0, best))));
}
check(worst < 0.6, "... and its axes are the room's");
}
// ---- rotation_between, including the half turn ----
{
const double a[3] = {0, 0, 1}, b[3] = {0, 0, -1};
double R[9];
al::rotation_between(a, b, R);
check(std::fabs(R[8] + 1.0) < 1e-12 &&
std::fabs(R[0]*R[4]*R[8] + R[1]*R[5]*R[6] + R[2]*R[3]*R[7] -
R[2]*R[4]*R[6] - R[1]*R[3]*R[8] - R[0]*R[5]*R[7] - 1.0) < 1e-9,
"opposite vectors: a proper half turn");
}
std::printf("%s\n", g_failures ? "FAILED" : "all passed");
return g_failures ? 1 : 0;
}
@@ -0,0 +1,166 @@
// Baking a placement into a model must not change what it looks like: render
// a scene, move the splats AND the camera by one similarity, render again.
// Every attribute checkpoint/SplatTransform.h touches is on that path -- the
// means, the orientation, the log scales, and the SH bands, whose rotation is
// the part a wrong sign survives everywhere except here. The control renders
// the moved model with its SH left alone and must NOT match.
//
// ./splat_transform_render (either backend, no reference file)
#include <checkpoint/SplatPly.h>
#include <checkpoint/SplatTransform.h>
#include <engine/Engine.h>
#include <engine/EngineState.h>
#include <cmath>
#include <cstdio>
#include <random>
#include <string>
#include <vector>
static constexpr int64_t N = 3000;
static constexpr int W = 200, H = 150;
static TorchTensorView ttv(const void* p, std::vector<int64_t> shape) {
return std::make_tuple((uint64_t)p, (uint32_t)4, std::move(shape));
}
static TorchTensorView ttv_null() {
return std::make_tuple((uint64_t)0, 4u, std::vector<int64_t>{0});
}
static int g_failures = 0;
static void check(bool ok, const std::string& what) {
std::printf("%s %s\n", ok ? "ok " : "FAIL", what.c_str());
if (!ok) g_failures++;
}
static std::vector<float> render(const spirula::SplatCloud& c,
const std::vector<float>& viewmat,
const char* prim, const char* cam, int degree) {
const int64_t K = c.dim_sh() - 1;
// set_data_3dgs is a no-op on an initialized world.
engine_reset();
set_data_3dgs(c.num, ttv(c.means.data(), {c.num, 3}),
ttv(c.quats.data(), {c.num, 4}),
ttv(c.scales.data(), {c.num, 3}),
ttv(c.opacities.data(), {c.num, 1}),
ttv(c.features_dc.data(), {c.num, 3}),
ttv(c.features_sh.data(), {c.num, K, 3}));
const std::vector<float> intr = {150, 150, W * 0.5f, H * 0.5f};
const std::vector<float> dist(kCameraDistortionParams, 0.0f);
set_camera_params(W, H, cam, "NONE", ttv(viewmat.data(), {1, 4, 4}),
ttv(intr.data(), {1, 4}),
ttv(dist.data(), {1, kCameraDistortionParams}));
forward_3dgs(prim, degree, false, false, 0);
backend::device_synchronize();
std::vector<float> rgb((size_t)H * W * 3);
engine_copy_render_to_host(ttv(rgb.data(), {1, H, W, 3}), ttv_null(),
ttv_null(), ttv_null(), ttv_null());
return rgb;
}
int main() {
std::mt19937 rng(20260921u);
auto uf = [&](float lo, float hi) {
return lo + (hi - lo) * (float)(rng() & 0xffffff) / 16777215.0f;
};
for (int degree : {3, 4}) {
spirula::SplatCloud c;
c.num = N;
c.sh_degree = degree;
const int64_t K = c.dim_sh() - 1;
c.means.resize(N * 3); c.quats.resize(N * 4); c.scales.resize(N * 3);
c.opacities.resize(N); c.features_dc.resize(N * 3);
c.features_sh.resize(N * K * 3);
for (int64_t i = 0; i < N; i++) {
c.means[3*i+0] = uf(-2.f, 2.f);
c.means[3*i+1] = uf(-1.5f, 1.5f);
c.means[3*i+2] = uf(-1.f, 1.f);
for (int k = 0; k < 4; k++) c.quats[4*i+k] = uf(-1.f, 1.f);
// Anisotropic on purpose: an isotropic Gaussian cannot tell a
// right orientation from a wrong one.
c.scales[3*i+0] = uf(-4.5f, -2.0f);
c.scales[3*i+1] = uf(-4.5f, -2.0f);
c.scales[3*i+2] = uf(-6.0f, -4.0f);
c.opacities[i] = uf(-1.f, 3.f);
for (int k = 0; k < 3; k++) c.features_dc[3*i+k] = uf(0.f, 1.5f);
}
// Strong view dependence, so a wrong band matrix is a wrong image.
for (float& v : c.features_sh) v = uf(-0.6f, 0.6f);
// Unit, as FusedGeometryOptim.cu leaves them after every step: the
// 3dgut rasterizer builds its rotation from the stored value as is.
for (int64_t i = 0; i < N; i++) {
float n = 0;
for (int k = 0; k < 4; k++) n += c.quats[4*i+k] * c.quats[4*i+k];
n = std::sqrt(n);
for (int k = 0; k < 4; k++) c.quats[4*i+k] /= n;
}
// Camera 5 units back along -z of the world, looking at the origin.
const std::vector<float> V = {1, 0, 0, 0.1f, 0, 1, 0, -0.05f,
0, 0, 1, 5.0f, 0, 0, 0, 1};
const double axis[3] = {0.48, -0.6, 0.64};
const double pivot[3] = {0.3, -0.2, 0.1};
spirula::Sim3 T = spirula::Sim3::rotation_about(axis, 1.1, pivot);
T.s = 1.7;
T.t[0] += 0.4; T.t[1] -= 0.7; T.t[2] += 0.25;
// x_cam = V T^-1 x', times s so the camera stays rigid: a pinhole
// does not see a uniform scale of camera space.
const spirula::Sim3 Ti = T.inverse();
std::vector<float> V2(16, 0.0f);
V2[15] = 1.0f;
for (int r = 0; r < 3; r++) {
for (int col = 0; col < 3; col++) {
double v = 0;
for (int k = 0; k < 3; k++) v += V[r*4+k] * Ti.R[k*3+col];
V2[r*4+col] = (float)v;
}
double t = 0;
for (int k = 0; k < 3; k++) t += V[r*4+k] * Ti.t[k];
V2[r*4+3] = (float)(T.s * (t + V[r*4+3]));
}
spirula::SplatCloud moved = c;
spirula::transform_splats(moved, T);
spirula::SplatCloud stale = moved;
stale.features_sh = c.features_sh;
const struct { const char* prim; const char* cam; } cfgs[] = {
{"3dgs", "PINHOLE"}, {"mip", "PINHOLE"}, {"3dgut", "PINHOLE"},
{"3dgs", "FISHEYE"}};
for (const auto& cfg : cfgs) {
const std::vector<float> a = render(c, V, cfg.prim, cfg.cam, degree);
const std::vector<float> b = render(moved, V2, cfg.prim, cfg.cam, degree);
const std::vector<float> s = render(stale, V2, cfg.prim, cfg.cam, degree);
if (const char* err = backend::last_error()) {
std::fprintf(stderr, "backend error: %s\n", err);
return 1;
}
double worst = 0, mean = 0, lit = 0, stale_mean = 0;
for (size_t i = 0; i < a.size(); i++) {
const double d = std::fabs((double)a[i] - b[i]);
worst = std::max(worst, d);
mean += d;
lit += std::fabs(a[i]);
stale_mean += std::fabs((double)a[i] - s[i]);
}
mean /= (double)a.size();
lit /= (double)a.size();
stale_mean /= (double)a.size();
std::printf(" SH %d %-5s %-8s mean |d| %.2e max %.2e "
"(image mean %.3f; SH left alone: %.2e)\n",
degree, cfg.prim, cfg.cam, mean, worst, lit, stale_mean);
const std::string tag = std::string("SH ") + std::to_string(degree) +
" " + cfg.prim + " " + cfg.cam;
check(lit > 0.05, tag + ": the render is not empty");
check(mean < 2e-4 && worst < 2e-2, tag + ": moved model = same image");
check(stale_mean > 20.0 * std::max(mean, 1e-6),
tag + ": un-rotated SH is visibly wrong");
}
}
std::printf("%s\n", g_failures ? "FAILED" : "all passed");
return g_failures ? 1 : 0;
}
+19 -5
View File
@@ -2,6 +2,8 @@
#include "checkpoint/SplatPly.h"
#include "checkpoint/SplatTransform.h"
#include <algorithm>
#include <cmath>
#include <cstring>
@@ -254,7 +256,7 @@ SplatCloud read_splat_ply(const std::string& path, bool want_sh) {
void write_splat_ply(const SplatCloud& c, const std::string& path,
const uint8_t* keep) {
const uint8_t* keep, const SplatTransform* moved) {
const int64_t K = c.dim_sh() - 1;
int64_t kept = c.num;
if (keep) {
@@ -287,21 +289,33 @@ void write_splat_ply(const SplatCloud& c, const std::string& path,
(std::streamsize)rows * row_floats * sizeof(float));
rows = 0;
};
const bool move = moved && !moved->is_identity();
std::vector<float> sh((size_t)std::max<int64_t>(K, 1) * 3);
for (int64_t i = 0; i < c.num; i++) {
if (keep && !keep[i]) continue;
float mean[3], quat[4], scale[3];
for (int a = 0; a < 3; a++) mean[a] = c.means[(size_t)i * 3 + a];
for (int a = 0; a < 3; a++) scale[a] = c.scales[(size_t)i * 3 + a];
for (int a = 0; a < 4; a++) quat[a] = c.quats[(size_t)i * 4 + a];
const float* rest = K > 0 ? &c.features_sh[(size_t)i * K * 3] : nullptr;
if (move) {
if (K > 0) std::copy(rest, rest + K * 3, sh.begin());
moved->apply(mean, quat, scale, K > 0 ? sh.data() : nullptr, (int)K);
rest = sh.data();
}
float* row = buf.data() + (size_t)rows * row_floats;
int p = 0;
for (int a = 0; a < 3; a++) row[p++] = c.means[(size_t)i * 3 + a];
for (int a = 0; a < 3; a++) row[p++] = mean[a];
row[p++] = 0.0f; row[p++] = 0.0f; row[p++] = 0.0f; // nx ny nz
for (int a = 0; a < 3; a++) row[p++] = c.features_dc[(size_t)i * 3 + a];
// A PLY stores f_rest channel-major; SplatCloud holds it
// coefficient-major, which is the transposition read_splat_ply undoes.
for (int ch = 0; ch < 3; ch++)
for (int64_t j = 0; j < K; j++)
row[p++] = c.features_sh[((size_t)i * K + j) * 3 + ch];
row[p++] = rest[(size_t)j * 3 + ch];
row[p++] = c.opacities[(size_t)i];
for (int a = 0; a < 3; a++) row[p++] = c.scales[(size_t)i * 3 + a];
for (int a = 0; a < 4; a++) row[p++] = c.quats[(size_t)i * 4 + a];
for (int a = 0; a < 3; a++) row[p++] = scale[a];
for (int a = 0; a < 4; a++) row[p++] = quat[a];
if (++rows == kRowsPerFlush) flush();
}
flush();
+5 -4
View File
@@ -51,11 +51,12 @@ bool is_splat_ply(const std::string& path);
// mesher only needs geometry and DC colour.
SplatCloud read_splat_ply(const std::string& path, bool want_sh = true);
// Write `c` back out in the property layout read_splat_ply expects and
// EngineCheckpoint.cpp's writer produces -- one file format, so a change to
// either belongs in both. `keep` is one flag per splat, or null for all.
// The layout read_splat_ply expects and EngineCheckpoint.cpp also writes: one
// format, so a change to either belongs in both. `keep` is a flag per splat;
// `moved` is applied row by row, so baking a placement needs no second copy.
void write_splat_ply(const SplatCloud& c, const std::string& path,
const uint8_t* keep = nullptr);
const uint8_t* keep = nullptr,
const class SplatTransform* moved = nullptr);
// Resolve what a user pointed at into (splat.ply, run directory): a .ply
// directly, a step-*.ckpt / *.ckpt directory holding one, or a run directory
+51
View File
@@ -0,0 +1,51 @@
// SplatTransform.cpp -- see SplatTransform.h.
#include "checkpoint/SplatTransform.h"
#include "checkpoint/SplatPly.h"
#include <cmath>
namespace spirula {
SplatTransform::SplatTransform(const Sim3& T, int sh_degree)
: _T(T), _log_s((float)std::log(T.s)), _identity(T.is_identity()),
_sh(T.R, sh_degree) {
_T.quat(_q);
}
void SplatTransform::apply(float mean[3], float quat[4], float log_scale[3],
float* rest, int coeffs) const {
if (_identity) return;
const double p[3] = {mean[0], mean[1], mean[2]};
double o[3];
_T.apply(p, o);
for (int i = 0; i < 3; i++) mean[i] = (float)o[i];
// Hamilton product q_R * q: the Gaussian's own frame, then the turn.
const double aw = _q[0], ax = _q[1], ay = _q[2], az = _q[3];
const double bw = quat[0], bx = quat[1], by = quat[2], bz = quat[3];
double r[4] = {aw*bw - ax*bx - ay*by - az*bz,
aw*bx + ax*bw + ay*bz - az*by,
aw*by - ax*bz + ay*bw + az*bx,
aw*bz + ax*by - ay*bx + az*bw};
// The stored quaternion is not normalized, and its length is the file's
// to keep: renormalizing here would be a second edit nobody asked for.
for (int i = 0; i < 4; i++) quat[i] = (float)r[i];
for (int i = 0; i < 3; i++) log_scale[i] += _log_s;
if (rest && coeffs > 0) _sh.apply(rest, coeffs);
}
void transform_splats(SplatCloud& c, const Sim3& T) {
const SplatTransform xf(T, c.sh_degree);
if (xf.is_identity()) return;
const int K = (int)c.dim_sh() - 1;
#pragma omp parallel for schedule(static)
for (int64_t i = 0; i < c.num; i++)
xf.apply(&c.means[(size_t)i * 3], &c.quats[(size_t)i * 4],
&c.scales[(size_t)i * 3],
K > 0 ? &c.features_sh[(size_t)i * K * 3] : nullptr, K);
}
} // namespace spirula
+43
View File
@@ -0,0 +1,43 @@
#pragma once
// Moving a trained model: what a similarity does to one Gaussian.
//
// mean -> s R mean + t quat -> q_R * quat
// scale -> scale + ln s (logs) SH -> rotated band by band
//
// Opacity and the DC colour do not change. The view-dependent bands do, or
// the highlights stay where they were while the object turns under them
// (core/ShRotation.h). One object per transform: the band matrices are built
// once and every row reuses them.
#include "core/ShRotation.h"
#include "core/Similarity.h"
#include <cstdint>
namespace spirula {
struct SplatCloud;
class SplatTransform {
public:
SplatTransform(const Sim3& T, int sh_degree);
bool is_identity() const { return _identity; }
// One Gaussian in place, in the raw layout checkpoint/SplatPly.h stores:
// log scales, a (w,x,y,z) quaternion, `rest` as [coeffs, 3].
void apply(float mean[3], float quat[4], float log_scale[3], float* rest,
int coeffs) const;
private:
Sim3 _T;
double _q[4];
float _log_s;
bool _identity;
ShRotation _sh;
};
// Every Gaussian of `c`, in place.
void transform_splats(SplatCloud& c, const Sim3& T);
} // namespace spirula
+117
View File
@@ -0,0 +1,117 @@
// ShRotation.cpp -- see ShRotation.h.
#include "core/ShRotation.h"
#include <algorithm>
#include <cmath>
#include <stdexcept>
namespace spirula {
namespace {
// A band matrix indexed by m, n in [-l, l].
struct Band {
const double* m;
int l;
double operator()(int a, int b) const {
return m[(size_t)(a + l) * (2 * l + 1) + (b + l)];
}
};
} // namespace
ShRotation::ShRotation(const double R[9], int degree)
: _degree(std::clamp(degree, 0, kMaxDegree)) {
if (degree > kMaxDegree)
throw std::runtime_error("ShRotation: degree above 4 is not supported");
_m[0] = {1.0};
if (_degree < 1) return;
// Band 1 of the SIGN-FREE real basis is (y, z, x), so its matrix is R
// under that permutation. The recursion below runs in that basis.
const int p[3] = {1, 2, 0};
std::vector<double> r1(9);
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++) r1[(size_t)i * 3 + j] = R[p[i] * 3 + p[j]];
_m[1] = r1;
const Band B1{_m[1].data(), 1};
for (int l = 2; l <= _degree; l++) {
const int w = 2 * l + 1;
_m[l].assign((size_t)w * w, 0.0);
const Band prev{_m[l - 1].data(), l - 1};
auto P = [&](int i, int a, int b) {
if (b == l)
return B1(i, 1) * prev(a, l - 1) - B1(i, -1) * prev(a, -l + 1);
if (b == -l)
return B1(i, 1) * prev(a, -l + 1) + B1(i, -1) * prev(a, l - 1);
return B1(i, 0) * prev(a, b);
};
for (int m = -l; m <= l; m++)
for (int n = -l; n <= l; n++) {
const int am = std::abs(m);
const double d = std::abs(n) == l ? (double)(2 * l) * (2 * l - 1)
: (double)(l + n) * (l - n);
const double d0 = m == 0 ? 1.0 : 0.0;
const double u = std::sqrt((double)(l + m) * (l - m) / d);
const double v = 0.5 * (1.0 - 2.0 * d0) *
std::sqrt((1.0 + d0) * (l + am - 1) * (l + am) / d);
const double ww = -0.5 * (1.0 - d0) *
std::sqrt((double)(l - am - 1) * (l - am) / d);
double acc = 0.0;
if (u != 0.0) acc += u * P(0, m, n);
if (v != 0.0) {
double V;
if (m == 0) V = P(1, 1, n) + P(-1, -1, n);
else if (m > 0)
V = m == 1 ? std::sqrt(2.0) * P(1, 0, n)
: P(1, m - 1, n) - P(-1, -m + 1, n);
else
V = m == -1 ? std::sqrt(2.0) * P(-1, 0, n)
: P(1, m + 1, n) + P(-1, -m - 1, n);
acc += v * V;
}
if (ww != 0.0)
acc += ww * (m > 0 ? P(1, m + 1, n) + P(-1, -m - 1, n)
: P(1, m - 1, n) - P(-1, -m + 1, n));
_m[l][(size_t)(m + l) * w + (n + l)] = acc;
}
}
// harmonics.slang carries the Condon-Shortley phase, (-1)^m on Y_lm, so
// its matrices are these conjugated by that diagonal.
for (int l = 1; l <= _degree; l++) {
const int w = 2 * l + 1;
for (int a = -l; a <= l; a++)
for (int b = -l; b <= l; b++)
if ((a + b) & 1) _m[l][(size_t)(a + l) * w + (b + l)] *= -1.0;
}
}
void ShRotation::apply(float* rest, int coeffs) const {
double tmp[2 * kMaxDegree + 1][3];
for (int l = 1; l <= _degree; l++) {
const int w = 2 * l + 1;
const int at = l * l - 1; // DC is not in `rest`
if (at + w > coeffs) break;
const double* M = _m[l].data();
for (int a = 0; a < w; a++) {
double acc[3] = {0, 0, 0};
for (int b = 0; b < w; b++) {
const double k = M[(size_t)a * w + b];
const float* c = rest + (size_t)(at + b) * 3;
acc[0] += k * c[0];
acc[1] += k * c[1];
acc[2] += k * c[2];
}
tmp[a][0] = acc[0]; tmp[a][1] = acc[1]; tmp[a][2] = acc[2];
}
for (int a = 0; a < w; a++) {
float* c = rest + (size_t)(at + a) * 3;
c[0] = (float)tmp[a][0]; c[1] = (float)tmp[a][1]; c[2] = (float)tmp[a][2];
}
}
}
} // namespace spirula
+39
View File
@@ -0,0 +1,39 @@
#pragma once
// Rotating spherical-harmonic colour with the model that carries it.
//
// For the real basis shaders/harmonics.slang evaluates there is, per band l,
// an orthogonal (2l+1)^2 matrix M_l with Y_l(R d) = M_l(R) Y_l(d). A model
// turned by R must look from R d the way it looked from d, so its band-l
// coefficients become M_l(R) c_l. M_l comes from M_1 and M_(l-1) by the
// Ivanic-Ruedenberg recursion (J. Phys. Chem. 100:6342, 1996; erratum 1998):
// closed form, no sampling. Derivation and the sign convention are in
// docs/notes/sh-rotation.md; core/tests/sh_rotation_test.cpp holds it to a
// sampled least-squares fit of that exact basis.
#include <vector>
namespace spirula {
class ShRotation {
public:
static constexpr int kMaxDegree = 4;
// `R` is row-major 3x3, a proper rotation. Bands 1..degree are built.
ShRotation(const double R[9], int degree);
int degree() const { return _degree; }
// M_l, row-major (2l+1) x (2l+1), rows and columns ordered m = -l..l.
const double* band(int l) const { return _m[l].data(); }
// One splat's rows in place: [coeffs, 3] coefficient-major, band 1 first
// and DC excluded, as checkpoint/SplatPly.h holds them. A band the rows
// do not complete is left alone.
void apply(float* rest, int coeffs) const;
private:
int _degree;
std::vector<double> _m[kMaxDegree + 1];
};
} // namespace spirula
+174
View File
@@ -0,0 +1,174 @@
#pragma once
// A similarity of 3-space, p' = s * R * p + t with s > 0 and R a proper
// rotation: the only transform a rigid scene can be given without changing
// what it is. Double throughout -- a geo-referenced model sits millions of
// units from its origin, where float resolves a metre.
//
// R is row-major. Header-only, host-only.
#include <cmath>
namespace spirula {
struct Sim3 {
double s = 1.0;
double R[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1};
double t[3] = {0, 0, 0};
bool is_identity(double eps = 1e-12) const {
if (std::fabs(s - 1.0) > eps) return false;
for (int i = 0; i < 9; i++)
if (std::fabs(R[i] - (i % 4 == 0 ? 1.0 : 0.0)) > eps) return false;
for (int i = 0; i < 3; i++)
if (std::fabs(t[i]) > eps * (1.0 + std::fabs(t[i]))) return false;
return true;
}
void apply(const double p[3], double out[3]) const {
const double x = p[0], y = p[1], z = p[2];
for (int r = 0; r < 3; r++)
out[r] = s * (R[r*3+0]*x + R[r*3+1]*y + R[r*3+2]*z) + t[r];
}
// A direction: rotated, neither scaled nor moved.
void rotate(const double v[3], double out[3]) const {
const double x = v[0], y = v[1], z = v[2];
for (int r = 0; r < 3; r++)
out[r] = R[r*3+0]*x + R[r*3+1]*y + R[r*3+2]*z;
}
// Row-major 3x4 [s*R | t], the layout every viewport matrix here uses.
template <typename T>
void to_3x4(T out[12]) const {
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++) out[r*4+c] = (T)(s * R[r*3+c]);
out[r*4+3] = (T)t[r];
}
}
// The 3x3 block is taken to be s*R; the rotation is re-orthonormalized,
// so a matrix that went through float comes back a rotation.
template <typename T>
static Sim3 from_3x4(const T a[12]) {
Sim3 o;
double M[9];
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++) M[r*3+c] = (double)a[r*4+c];
o.t[r] = (double)a[r*4+3];
}
const double det =
M[0]*(M[4]*M[8]-M[5]*M[7]) - M[1]*(M[3]*M[8]-M[5]*M[6]) +
M[2]*(M[3]*M[7]-M[4]*M[6]);
o.s = std::cbrt(std::fabs(det));
if (!(o.s > 1e-300)) { o.s = 1.0; return o; }
for (int i = 0; i < 9; i++) o.R[i] = M[i] / o.s;
o.orthonormalize();
return o;
}
// Gram-Schmidt on the rows, third row from the cross product so the
// result is a proper rotation whatever rounding did to the input.
void orthonormalize() {
double* a = R; double* b = R + 3; double* c = R + 6;
double n = std::sqrt(a[0]*a[0] + a[1]*a[1] + a[2]*a[2]);
if (!(n > 1e-300)) return reset_rotation();
for (int i = 0; i < 3; i++) a[i] /= n;
double d = a[0]*b[0] + a[1]*b[1] + a[2]*b[2];
for (int i = 0; i < 3; i++) b[i] -= d * a[i];
n = std::sqrt(b[0]*b[0] + b[1]*b[1] + b[2]*b[2]);
if (!(n > 1e-300)) return reset_rotation();
for (int i = 0; i < 3; i++) b[i] /= n;
c[0] = a[1]*b[2] - a[2]*b[1];
c[1] = a[2]*b[0] - a[0]*b[2];
c[2] = a[0]*b[1] - a[1]*b[0];
}
Sim3 inverse() const {
Sim3 o;
o.s = 1.0 / s;
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++) o.R[r*3+c] = R[c*3+r];
for (int r = 0; r < 3; r++)
o.t[r] = -o.s * (o.R[r*3+0]*t[0] + o.R[r*3+1]*t[1] + o.R[r*3+2]*t[2]);
return o;
}
// (w, x, y, z), unit, w >= 0.
void quat(double q[4]) const {
const double m00 = R[0], m01 = R[1], m02 = R[2];
const double m10 = R[3], m11 = R[4], m12 = R[5];
const double m20 = R[6], m21 = R[7], m22 = R[8];
const double tr = m00 + m11 + m22;
if (tr > 0) {
const double k = 0.5 / std::sqrt(tr + 1.0);
q[0] = 0.25 / k; q[1] = (m21 - m12) * k;
q[2] = (m02 - m20) * k; q[3] = (m10 - m01) * k;
} else if (m00 > m11 && m00 > m22) {
const double k = 2.0 * std::sqrt(1.0 + m00 - m11 - m22);
q[1] = 0.25 * k; q[2] = (m01 + m10) / k;
q[3] = (m02 + m20) / k; q[0] = (m21 - m12) / k;
} else if (m11 > m22) {
const double k = 2.0 * std::sqrt(1.0 + m11 - m00 - m22);
q[1] = (m01 + m10) / k; q[2] = 0.25 * k;
q[3] = (m12 + m21) / k; q[0] = (m02 - m20) / k;
} else {
const double k = 2.0 * std::sqrt(1.0 + m22 - m00 - m11);
q[1] = (m02 + m20) / k; q[2] = (m12 + m21) / k;
q[3] = 0.25 * k; q[0] = (m10 - m01) / k;
}
double n = std::sqrt(q[0]*q[0] + q[1]*q[1] + q[2]*q[2] + q[3]*q[3]);
if (q[0] < 0) n = -n;
for (int i = 0; i < 4; i++) q[i] /= n;
}
static Sim3 translation(const double d[3]) {
Sim3 o;
for (int i = 0; i < 3; i++) o.t[i] = d[i];
return o;
}
// Rotation by `angle` radians about the unit `axis` through `pivot`.
static Sim3 rotation_about(const double axis[3], double angle,
const double pivot[3]) {
Sim3 o;
const double c = std::cos(angle), sn = std::sin(angle), k = 1.0 - c;
const double x = axis[0], y = axis[1], z = axis[2];
const double M[9] = {c + x*x*k, x*y*k - z*sn, x*z*k + y*sn,
y*x*k + z*sn, c + y*y*k, y*z*k - x*sn,
z*x*k - y*sn, z*y*k + x*sn, c + z*z*k};
for (int i = 0; i < 9; i++) o.R[i] = M[i];
o.pin(pivot);
return o;
}
static Sim3 scale_about(double factor, const double pivot[3]) {
Sim3 o;
o.s = factor;
o.pin(pivot);
return o;
}
private:
void reset_rotation() {
for (int i = 0; i < 9; i++) R[i] = i % 4 == 0 ? 1.0 : 0.0;
}
// Choose t so that `pivot` maps to itself.
void pin(const double pivot[3]) {
double q[3];
rotate(pivot, q);
for (int i = 0; i < 3; i++) t[i] = pivot[i] - s * q[i];
}
};
// a after b: (a * b)(p) = a(b(p)).
inline Sim3 operator*(const Sim3& a, const Sim3& b) {
Sim3 o;
o.s = a.s * b.s;
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++) {
double v = 0.0;
for (int k = 0; k < 3; k++) v += a.R[r*3+k] * b.R[k*3+c];
o.R[r*3+c] = v;
}
a.apply(b.t, o.t);
return o;
}
} // namespace spirula
+241
View File
@@ -0,0 +1,241 @@
// sh_rotation_test -- core/ShRotation.h against the basis it has to match.
//
// The reference is the slow, obviously-right method: sample directions on the
// sphere, evaluate the basis at d and at R d, and solve for the matrix that
// maps one to the other. The closed form must agree with it to rounding, be
// orthogonal, compose like the rotations do, and leave a rotated model
// looking from R d exactly as the original looked from d.
#include "core/ShRotation.h"
#include <array>
#include <cmath>
#include <cstdio>
#include <random>
#include <string>
#include <vector>
namespace {
int g_failures = 0;
void check(bool ok, const std::string& what) {
std::printf("%s %s\n", ok ? "ok " : "FAIL", what.c_str());
if (!ok) g_failures++;
}
// The 25 basis values of shaders/harmonics.slang sh_coeffs_to_color, constant
// for constant: a change there has to be a change here.
void basis(const double d[3], double o[25]) {
const double x = d[0], y = d[1], z = d[2];
o[0] = 0.2820947917738781;
const double c1 = 0.48860251190292;
o[1] = -c1 * y; o[2] = c1 * z; o[3] = -c1 * x;
const double z2 = z * z;
const double fTmp0B = -1.092548430592079 * z;
const double fTmp1A = 0.5462742152960395;
const double fC1 = x * x - y * y, fS1 = 2.0 * x * y;
o[6] = 0.9461746957575601 * z2 - 0.3153915652525201;
o[7] = fTmp0B * x; o[5] = fTmp0B * y;
o[8] = fTmp1A * fC1; o[4] = fTmp1A * fS1;
const double fTmp0C = -2.285228997322329 * z2 + 0.4570457994644658;
const double fTmp1B = 1.445305721320277 * z;
const double fTmp2A = -0.5900435899266435;
const double fC2 = x * fC1 - y * fS1, fS2 = x * fS1 + y * fC1;
o[12] = z * (1.865881662950577 * z2 - 1.119528997770346);
o[13] = fTmp0C * x; o[11] = fTmp0C * y;
o[14] = fTmp1B * fC1; o[10] = fTmp1B * fS1;
o[15] = fTmp2A * fC2; o[9] = fTmp2A * fS2;
const double fTmp0D = z * (-4.683325804901025 * z2 + 2.007139630671868);
const double fTmp1C = 3.31161143515146 * z2 - 0.47308734787878;
const double fTmp2B = -1.770130769779931 * z;
const double fC3 = x * fC2 - y * fS2, fS3 = x * fS2 + y * fC2;
o[20] = 1.984313483298443 * z * o[12] - 1.006230589874905 * o[6];
o[21] = fTmp0D * x; o[19] = fTmp0D * y;
o[22] = fTmp1C * fC1; o[18] = fTmp1C * fS1;
o[23] = fTmp2B * fC2; o[17] = fTmp2B * fS2;
o[24] = 0.6258357354491763 * fC3; o[16] = 0.6258357354491763 * fS3;
}
void quat_to_R(const double q[4], double R[9]) {
const double w = q[0], x = q[1], y = q[2], z = q[3];
const double M[9] = {1-2*(y*y+z*z), 2*(x*y-z*w), 2*(x*z+y*w),
2*(x*y+z*w), 1-2*(x*x+z*z), 2*(y*z-x*w),
2*(x*z-y*w), 2*(y*z+x*w), 1-2*(x*x+y*y)};
for (int i = 0; i < 9; i++) R[i] = M[i];
}
void rotate(const double R[9], const double d[3], double o[3]) {
for (int r = 0; r < 3; r++)
o[r] = R[r*3+0]*d[0] + R[r*3+1]*d[1] + R[r*3+2]*d[2];
}
void matmul3(const double a[9], const double b[9], double o[9]) {
for (int r = 0; r < 3; r++)
for (int c = 0; c < 3; c++) {
o[r*3+c] = 0;
for (int k = 0; k < 3; k++) o[r*3+c] += a[r*3+k] * b[k*3+c];
}
}
// Gaussian elimination with partial pivoting, A [n,n] and B [n,m] in place.
bool solve(std::vector<double>& A, std::vector<double>& B, int n, int m) {
for (int c = 0; c < n; c++) {
int best = c;
for (int r = c + 1; r < n; r++)
if (std::fabs(A[r*n+c]) > std::fabs(A[best*n+c])) best = r;
if (std::fabs(A[best*n+c]) < 1e-14) return false;
for (int k = 0; k < n; k++) std::swap(A[c*n+k], A[best*n+k]);
for (int k = 0; k < m; k++) std::swap(B[c*m+k], B[best*m+k]);
for (int r = 0; r < n; r++) {
if (r == c) continue;
const double f = A[r*n+c] / A[c*n+c];
for (int k = c; k < n; k++) A[r*n+k] -= f * A[c*n+k];
for (int k = 0; k < m; k++) B[r*m+k] -= f * B[c*m+k];
}
}
for (int r = 0; r < n; r++)
for (int k = 0; k < m; k++) B[r*m+k] /= A[r*n+r];
return true;
}
// M with Y_l(R d) = M Y_l(d), by least squares over `dirs`.
std::vector<double> sampled_band(const double R[9], int l,
const std::vector<double>& dirs) {
const int w = 2 * l + 1, at = l * l;
std::vector<double> AtA((size_t)w * w, 0.0), AtB((size_t)w * w, 0.0);
for (size_t i = 0; i + 2 < dirs.size(); i += 3) {
double a[25], b[25], rd[3];
basis(&dirs[i], a);
rotate(R, &dirs[i], rd);
basis(rd, b);
for (int r = 0; r < w; r++)
for (int c = 0; c < w; c++) {
AtA[(size_t)r*w+c] += a[at+r] * a[at+c];
AtB[(size_t)r*w+c] += a[at+r] * b[at+c];
}
}
solve(AtA, AtB, w, w); // AtB = X with A X = B, X = M^T
std::vector<double> M((size_t)w * w);
for (int r = 0; r < w; r++)
for (int c = 0; c < w; c++) M[(size_t)r*w+c] = AtB[(size_t)c*w+r];
return M;
}
double eval(const std::vector<float>& rest, int degree, const double d[3]) {
double y[25];
basis(d, y);
double acc = 0.0;
const int n = (degree + 1) * (degree + 1);
for (int j = 1; j < n; j++) acc += y[j] * rest[(size_t)(j - 1) * 3];
return acc;
}
} // namespace
int main() {
std::mt19937 rng(11);
std::normal_distribution<double> g(0.0, 1.0);
auto unit = [&](int n, double* o) {
double s = 0;
for (int i = 0; i < n; i++) { o[i] = g(rng); s += o[i] * o[i]; }
s = std::sqrt(s);
for (int i = 0; i < n; i++) o[i] /= s;
};
std::vector<double> dirs(3 * 600);
for (size_t i = 0; i < dirs.size(); i += 3) unit(3, &dirs[i]);
// Random rotations plus the ones most likely to expose a sign or an
// ordering slip: quarter and half turns about each axis, and identity.
std::vector<std::array<double, 9>> rots;
for (int k = 0; k < 24; k++) {
double q[4];
unit(4, q);
std::array<double, 9> R;
quat_to_R(q, R.data());
rots.push_back(R);
}
const double h = std::sqrt(0.5);
const double special[][4] = {{1,0,0,0}, {h,h,0,0}, {h,0,h,0}, {h,0,0,h},
{0,1,0,0}, {0,0,1,0}, {0,0,0,1},
{h,-h,0,0}, {0.5,0.5,0.5,0.5}};
for (const auto& q : special) {
std::array<double, 9> R;
quat_to_R(q, R.data());
rots.push_back(R);
}
double worst_fit = 0, worst_orth = 0;
for (const auto& R : rots) {
const spirula::ShRotation sh(R.data(), 4);
for (int l = 1; l <= 4; l++) {
const int w = 2 * l + 1;
const std::vector<double> ref = sampled_band(R.data(), l, dirs);
const double* M = sh.band(l);
for (int i = 0; i < w * w; i++)
worst_fit = std::max(worst_fit, std::fabs(M[i] - ref[(size_t)i]));
for (int r = 0; r < w; r++)
for (int c = 0; c < w; c++) {
double v = 0;
for (int k = 0; k < w; k++) v += M[r*w+k] * M[c*w+k];
worst_orth = std::max(worst_orth,
std::fabs(v - (r == c ? 1.0 : 0.0)));
}
}
}
std::printf(" closed form vs sampled fit: max |diff| = %.3g\n", worst_fit);
check(worst_fit < 1e-9, "bands 1-4 equal the sampled least-squares fit");
check(worst_orth < 1e-12, "every band matrix is orthogonal");
double worst_comp = 0;
for (size_t i = 0; i + 1 < rots.size(); i += 2) {
double R12[9];
matmul3(rots[i].data(), rots[i + 1].data(), R12);
const spirula::ShRotation a(rots[i].data(), 4), b(rots[i + 1].data(), 4),
ab(R12, 4);
for (int l = 1; l <= 4; l++) {
const int w = 2 * l + 1;
for (int r = 0; r < w; r++)
for (int c = 0; c < w; c++) {
double v = 0;
for (int k = 0; k < w; k++)
v += a.band(l)[r*w+k] * b.band(l)[k*w+c];
worst_comp = std::max(worst_comp,
std::fabs(v - ab.band(l)[r*w+c]));
}
}
}
check(worst_comp < 1e-12, "M(R1 R2) = M(R1) M(R2)");
// What it is for: the turned model seen from R d is the model seen from d.
for (int degree = 0; degree <= 4; degree++) {
const int n = (degree + 1) * (degree + 1) - 1;
double worst = 0;
for (const auto& R : rots) {
std::vector<float> c((size_t)std::max(n, 1) * 3);
for (float& v : c) v = (float)g(rng);
std::vector<float> turned = c;
spirula::ShRotation(R.data(), degree).apply(turned.data(), n);
for (size_t i = 0; i < 60 * 3; i += 3) {
double rd[3];
rotate(R.data(), &dirs[i], rd);
worst = std::max(worst, std::fabs(eval(turned, degree, rd) -
eval(c, degree, &dirs[i])));
}
}
check(worst < 2e-5, "degree " + std::to_string(degree) +
": colour(R d) of the turned model = colour(d)");
}
// A file with fewer bands than the rotation was built for.
{
std::vector<float> c(8 * 3, 1.0f), was = c;
spirula::ShRotation(rots[0].data(), 4).apply(c.data(), 3);
bool tail_kept = true;
for (size_t i = 9; i < c.size(); i++) tail_kept &= c[i] == was[i];
check(tail_kept, "rows past the last complete band are left alone");
}
std::printf("%s\n", g_failures ? "FAILED" : "all passed");
return g_failures ? 1 : 0;
}
+14 -1
View File
@@ -10,6 +10,7 @@
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
@@ -47,6 +48,18 @@ inline std::string json_number(double v) {
return buf;
}
// The shortest spelling that reads back as the same double: a document that
// was parsed and is written again must not come back rounded.
inline std::string json_number_exact(double v) {
if (std::isnan(v) || std::isinf(v)) return json_number(v);
char buf[40];
for (int digits = 15; digits <= 17; digits++) {
std::snprintf(buf, sizeof buf, "%.*g", digits, v);
if (std::strtod(buf, nullptr) == v) break;
}
return buf;
}
class JsonWriter {
public:
JsonWriter& object() { return open('{', '}'); }
@@ -134,7 +147,7 @@ inline void json_write(JsonWriter& w, const JsonValue& v) {
w.end();
break;
case JsonValue::Type::String: w.value(v.str); break;
case JsonValue::Type::Number: w.value(v.num); break;
case JsonValue::Type::Number: w.raw(json_number_exact(v.num)); break;
case JsonValue::Type::Bool: w.value(v.b); break;
default: w.raw("null"); break;
}
+280 -74
View File
@@ -71,12 +71,40 @@ void put_le(std::string& out, T v) {
out.append(reinterpret_cast<const char*>(&v), sizeof(T));
}
// COLMAP's world-to-camera pose (q as w,x,y,z) under x' = s Q x + u. A camera
// is rigid and cannot carry s, so its frame grows instead: R' = R Q^T,
// t' = s t - R' u. docs/notes/scene-transform.md.
void move_w2c(const Sim3& T, double q[4], double t[3]) {
double qt[4];
T.quat(qt);
// q * conj(qt)
const double aw = q[0], ax = q[1], ay = q[2], az = q[3];
const double bw = qt[0], bx = -qt[1], by = -qt[2], bz = -qt[3];
double r[4] = {aw*bw - ax*bx - ay*by - az*bz,
aw*bx + ax*bw + ay*bz - az*by,
aw*by - ax*bz + ay*bw + az*bx,
aw*bz + ax*by - ay*bx + az*bw};
double n = std::sqrt(r[0]*r[0] + r[1]*r[1] + r[2]*r[2] + r[3]*r[3]);
if (!(n > 1e-300)) return;
if (r[0] < 0) n = -n;
for (int i = 0; i < 4; i++) r[i] /= n;
const double w = r[0], x = r[1], y = r[2], z = r[3];
const double R[9] = {1-2*(y*y+z*z), 2*(x*y-z*w), 2*(x*z+y*w),
2*(x*y+z*w), 1-2*(x*x+z*z), 2*(y*z-x*w),
2*(x*z-y*w), 2*(y*z+x*w), 1-2*(x*x+y*y)};
for (int i = 0; i < 3; i++)
t[i] = T.s * t[i] - (R[i*3+0]*T.t[0] + R[i*3+1]*T.t[1] + R[i*3+2]*T.t[2]);
for (int i = 0; i < 4; i++) q[i] = r[i];
}
// images.bin: a count, then per image an id, a pose, a camera id, a
// null-terminated name and the 2D observations. Rows are copied byte for
// byte, so nothing an edit did not ask about is rewritten.
std::string filter_images_bin(const std::string& src,
const std::set<std::string>& drop,
std::set<int32_t>& dropped_ids) {
std::set<int32_t>& dropped_ids,
const Sim3* moved) {
const char* p = src.data();
const char* end = src.data() + src.size();
if ((size_t)(end - p) < sizeof(uint64_t))
@@ -106,18 +134,52 @@ std::string filter_images_bin(const std::string& src,
dropped_ids.insert(id);
continue;
}
const size_t at = out.size();
out.append(row, (size_t)(p - row));
if (moved) {
double pose[7];
std::memcpy(pose, &out[at + 4], sizeof pose);
move_w2c(*moved, pose, pose + 4);
std::memcpy(&out[at + 4], pose, sizeof pose);
}
kept++;
}
std::memcpy(&out[0], &kept, sizeof(uint64_t));
return out;
}
// frames.bin (COLMAP 3.12+): per frame an id, a rig id, rig_from_world, then
// its data ids. "" when the layout does not account for every byte -- a file
// this cannot read exactly is one it must not rewrite.
std::string move_frames_bin(const std::string& src, const Sim3& moved) {
const char* p = src.data();
const char* end = src.data() + src.size();
if ((size_t)(end - p) < sizeof(uint64_t)) return {};
const uint64_t n = read_le<uint64_t>(p);
std::string out = src;
for (uint64_t i = 0; i < n; i++) {
if (end - p < 4 + 4 + 8 * 7 + 4) return {};
p += 8;
const size_t at = (size_t)(p - src.data());
double pose[7];
std::memcpy(pose, p, sizeof pose);
move_w2c(moved, pose, pose + 4);
std::memcpy(&out[at], pose, sizeof pose);
p += sizeof pose;
const uint32_t ids = read_le<uint32_t>(p);
const size_t bytes = (size_t)ids * (4 + 4 + 8);
if ((size_t)(end - p) < bytes) return {};
p += bytes;
}
return p == end ? out : std::string();
}
// points3D.bin: the same, except that a track entry naming a dropped image
// has to go with it, which makes the row a rewrite rather than a copy.
std::string filter_points3d_bin(const std::string& src,
const std::vector<uint8_t>& keep,
const std::set<int32_t>& dropped_ids) {
const std::set<int32_t>& dropped_ids,
const Sim3* moved) {
const char* p = src.data();
const char* end = src.data() + src.size();
if ((size_t)(end - p) < sizeof(uint64_t))
@@ -140,7 +202,14 @@ std::string filter_points3d_bin(const std::string& src,
throw std::runtime_error("points3D.bin is truncated");
p += track_bytes;
if (i < keep.size() && !keep[(size_t)i]) continue;
const size_t at = out.size();
out.append(head, head_bytes);
if (moved) {
double xyz[3], q[3];
std::memcpy(xyz, &out[at + 8], sizeof xyz);
moved->apply(xyz, q);
std::memcpy(&out[at + 8], q, sizeof q);
}
if (dropped_ids.empty()) {
put_le<uint64_t>(out, track);
out.append(track_at, track_bytes);
@@ -168,7 +237,8 @@ std::string filter_points3d_bin(const std::string& src,
// a record is two lines and the second may be empty.
std::string filter_images_txt(const std::string& src,
const std::set<std::string>& drop,
std::set<int32_t>& dropped_ids) {
std::set<int32_t>& dropped_ids,
const Sim3* moved) {
std::string out;
out.reserve(src.size());
size_t pos = 0;
@@ -198,14 +268,24 @@ std::string filter_images_txt(const std::string& src,
char name[1024] = {0};
double d[7];
int cam = 0;
if (std::sscanf(line.c_str() + b, "%d %lf %lf %lf %lf %lf %lf %lf %d %1023s",
const bool parsed =
std::sscanf(line.c_str() + b, "%d %lf %lf %lf %lf %lf %lf %lf %d %1023s",
&id, &d[0], &d[1], &d[2], &d[3], &d[4], &d[5], &d[6],
&cam, name) == 10 &&
drop.count(leaf_of(name))) {
&cam, name) == 10;
if (parsed && drop.count(leaf_of(name))) {
dropped_ids.insert(id);
continue;
}
(void)obs_start;
if (parsed && moved) {
move_w2c(*moved, d, d + 4);
char buf[512];
std::snprintf(buf, sizeof buf,
"%d %.17g %.17g %.17g %.17g %.17g %.17g %.17g %d %s\n",
id, d[0], d[1], d[2], d[3], d[4], d[5], d[6], cam, name);
out += buf;
out.append(src, obs_start, pos - obs_start);
continue;
}
out.append(src, start, pos - start);
}
return out;
@@ -213,7 +293,8 @@ std::string filter_images_txt(const std::string& src,
std::string filter_points3d_txt(const std::string& src,
const std::vector<uint8_t>& keep,
const std::set<int32_t>& dropped_ids) {
const std::set<int32_t>& dropped_ids,
const Sim3* moved) {
std::string out;
out.reserve(src.size());
size_t pos = 0, index = 0;
@@ -235,24 +316,35 @@ std::string filter_points3d_txt(const std::string& src,
pos = next;
continue;
}
if (dropped_ids.empty()) {
if (dropped_ids.empty() && !moved) {
out.append(src, pos, std::min(next, src.size()) - pos);
pos = next;
continue;
}
// Rewrite the track: the first eight fields are the point, the rest
// is (image_id, point2D_idx) pairs.
// Rewrite the row: the first eight fields are the point -- its id,
// xyz, rgb and error -- and the rest is (image_id, point2D_idx) pairs.
std::string line = src.substr(b, t - b);
const char* s = line.c_str();
char* q = nullptr;
std::string head;
double xyz[3] = {0, 0, 0};
size_t xyz_from = 0, xyz_to = 0;
for (int f = 0; f < 8; f++) {
const double v = std::strtod(s, &q);
if (q == s) break;
(void)v;
if (f == 1) xyz_from = head.size();
if (f >= 1 && f <= 3) xyz[f - 1] = v;
head.append(s, (size_t)(q - s));
if (f == 3) xyz_to = head.size();
s = q;
}
if (moved && xyz_to > xyz_from) {
double o[3];
moved->apply(xyz, o);
char buf[128];
std::snprintf(buf, sizeof buf, " %.17g %.17g %.17g", o[0], o[1], o[2]);
head.replace(xyz_from, xyz_to - xyz_from, buf);
}
std::string track;
while (true) {
const long a = std::strtol(s, &q, 10);
@@ -301,6 +393,77 @@ bool drop_frames(JsonValue& meta, const std::set<std::string>& drop) {
return changed;
}
// A transforms.json holds its poses in the frame applied_transform maps the
// raw one INTO, so the same placement there is the conjugate A T A^-1, and
// applied_transform itself is left alone. docs/notes/scene-transform.md.
Sim3 to_json_frame(const JsonValue& meta, const Sim3& T) {
const JsonValue* at = meta.find("applied_transform");
if (!at || !at->is_array() || at->arr.size() < 3) return T;
double A[16] = {1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1};
for (int r = 0; r < 3; r++) {
const JsonValue& row = at->arr[(size_t)r];
if (!row.is_array() || row.arr.size() < 4) return T;
for (int c = 0; c < 4; c++) A[r*4+c] = row.arr[(size_t)c].as_double();
}
double Ai[16], M[16] = {0}, tmp[16], out[16];
dsparse::invert_affine4x4(A, Ai);
double m34[12];
T.to_3x4(m34);
for (int i = 0; i < 12; i++) M[i] = m34[i];
M[15] = 1.0;
auto mul = [](const double* a, const double* b, double* o) {
for (int r = 0; r < 4; r++)
for (int c = 0; c < 4; c++) {
double v = 0.0;
for (int k = 0; k < 4; k++) v += a[r*4+k] * b[k*4+c];
o[r*4+c] = v;
}
};
mul(A, M, tmp);
mul(tmp, Ai, out);
return Sim3::from_3x4(out);
}
// Camera-to-world: the position moves with the scene, the axes only turn --
// a transform_matrix whose columns stopped being unit would be a lens.
void move_frames(JsonValue& meta, const Sim3& T) {
for (auto& [k, frames] : meta.obj) {
if (k != "frames" || !frames.is_array()) continue;
for (JsonValue& f : frames.arr)
for (auto& [fk, tm] : f.obj) {
if (fk != "transform_matrix" || !tm.is_array() ||
tm.arr.size() < 3)
continue;
double m[12];
bool ok = true;
for (int r = 0; r < 3 && ok; r++) {
ok = tm.arr[(size_t)r].is_array() &&
tm.arr[(size_t)r].arr.size() >= 4;
for (int c = 0; c < 4 && ok; c++)
m[r*4+c] = tm.arr[(size_t)r].arr[(size_t)c].as_double();
}
if (!ok) continue;
double o[12];
for (int c = 0; c < 3; c++) {
const double v[3] = {m[0*4+c], m[1*4+c], m[2*4+c]};
double w[3];
T.rotate(v, w);
for (int r = 0; r < 3; r++) o[r*4+c] = w[r];
}
const double pos[3] = {m[3], m[7], m[11]};
double q[3];
T.apply(pos, q);
for (int r = 0; r < 3; r++) o[r*4+3] = q[r];
for (int r = 0; r < 3; r++)
for (int c = 0; c < 4; c++) {
JsonValue& cell = tm.arr[(size_t)r].arr[(size_t)c];
cell.type = JsonValue::Type::Number;
cell.num = o[r*4+c];
}
}
}
}
void set_string(JsonValue& obj, const char* key, const std::string& value) {
for (auto& [k, v] : obj.obj)
if (k == key) {
@@ -375,7 +538,8 @@ std::string resolve_sparse_dir(const std::string& path) {
void write_ply_points(const std::string& path, const double* xyz,
const uint8_t* rgb, int64_t n, const uint8_t* keep) {
const uint8_t* rgb, int64_t n, const uint8_t* keep,
const Sim3* moved) {
int64_t kept = n;
if (keep) {
kept = 0;
@@ -392,8 +556,9 @@ void write_ply_points(const std::string& path, const double* xyz,
f << "end_header\n";
for (int64_t i = 0; i < n; i++) {
if (keep && !keep[i]) continue;
const float p[3] = {(float)xyz[i * 3], (float)xyz[i * 3 + 1],
(float)xyz[i * 3 + 2]};
double q[3] = {xyz[i * 3], xyz[i * 3 + 1], xyz[i * 3 + 2]};
if (moved) moved->apply(&xyz[i * 3], q);
const float p[3] = {(float)q[0], (float)q[1], (float)q[2]};
f.write(reinterpret_cast<const char*>(p), sizeof p);
const uint8_t c[3] = {rgb ? rgb[i * 3] : (uint8_t)200,
rgb ? rgb[i * 3 + 1] : (uint8_t)200,
@@ -405,77 +570,118 @@ void write_ply_points(const std::string& path, const double* xyz,
}
std::vector<std::string> sparse_write_filtered(const std::string& dataset_dir,
const SparseKeep& keep) {
std::vector<std::string> written;
const std::set<std::string> drop = leaf_set(keep.drop_images);
switch (sparse_format_of(dataset_dir)) {
namespace {
// Read everything an edit of `dataset_dir` starts from.
SparseBaseline load_baseline(const std::string& dataset_dir) {
SparseBaseline b;
b.format = sparse_format_of(dataset_dir);
std::error_code ec;
switch (b.format) {
case SparseFormat::Colmap: {
bool text = false;
const std::string model = find_colmap_model(dataset_dir, "", &text);
if (model.empty())
b.model_dir = find_colmap_model(dataset_dir, "", &b.text);
if (b.model_dir.empty())
throw std::runtime_error("no COLMAP points3D under " + dataset_dir);
std::set<int32_t> dropped_ids;
if (!drop.empty()) {
const fs::path ip = fs::path(model) /
(text ? "images.txt" : "images.bin");
const std::string src = read_file(ip);
const std::string body =
text ? filter_images_txt(src, drop, dropped_ids)
: filter_images_bin(src, drop, dropped_ids);
keep_original(ip);
write_file(ip, body);
written.push_back(ip.string());
}
const fs::path pp = fs::path(model) /
(text ? "points3D.txt" : "points3D.bin");
const std::string src = read_file(pp);
keep_original(pp);
write_file(pp, text ? filter_points3d_txt(src, keep.points, dropped_ids)
: filter_points3d_bin(src, keep.points, dropped_ids));
written.push_back(pp.string());
const fs::path m(b.model_dir);
b.images = read_file(m / (b.text ? "images.txt" : "images.bin"));
b.points = read_file(m / (b.text ? "points3D.txt" : "points3D.bin"));
if (!b.text && fs::exists(m / "frames.bin", ec))
b.frames = read_file(m / "frames.bin");
break;
}
case SparseFormat::Nerfstudio: {
const fs::path meta_path = fs::path(dataset_dir) / "transforms.json";
JsonValue meta = json_parse(read_file(meta_path));
std::string rel = nerf_ply_rel(meta);
if (rel.empty()) rel = "points3D.ply";
const fs::path ply = fs::path(dataset_dir) / rel;
std::error_code ec;
ColmapPoints3D pts;
if (fs::exists(ply, ec)) {
pts = read_ply_points(ply.string());
keep_original(ply);
}
write_ply_points(ply.string(), pts.xyz.data(),
pts.rgb.empty() ? nullptr : pts.rgb.data(),
pts.num(), keep.points.data());
written.push_back(ply.string());
const bool frames_changed = drop_frames(meta, drop);
if (frames_changed || !meta.has("ply_file_path")) {
set_string(meta, "ply_file_path", rel);
keep_original(meta_path);
JsonWriter w;
json_write(w, meta);
write_file(meta_path, w.str());
written.push_back(meta_path.string());
}
b.meta = read_file(fs::path(dataset_dir) / "transforms.json");
b.ply_rel = nerf_ply_rel(json_parse(b.meta));
if (!b.ply_rel.empty() &&
fs::exists(fs::path(dataset_dir) / b.ply_rel, ec))
b.cloud = read_ply_points((fs::path(dataset_dir) / b.ply_rel).string());
break;
}
case SparseFormat::Metashape: {
DatasetParserConfig cfg;
JsonValue meta = metashape_meta(dataset_dir, cfg);
const ColmapPoints3D pts =
read_points_of(dataset_dir, nerf_ply_rel(meta));
const fs::path ply = fs::path(dataset_dir) / "points3D_edited.ply";
write_ply_points(ply.string(), pts.xyz.data(),
pts.rgb.empty() ? nullptr : pts.rgb.data(),
pts.num(), keep.points.data());
const JsonValue meta = metashape_meta(dataset_dir, cfg);
b.cloud = read_points_of(dataset_dir, nerf_ply_rel(meta));
JsonWriter w;
json_write(w, meta);
b.meta = w.str();
break;
}
default:
throw std::runtime_error("no reconstruction to write back in " +
dataset_dir);
}
return b;
}
} // namespace
std::vector<std::string> sparse_write_filtered(const std::string& dataset_dir,
const SparseKeep& keep,
const Sim3* moved,
SparseBaseline* base) {
if (moved && moved->is_identity()) moved = nullptr;
SparseBaseline local;
if (!base) base = &local;
if (!base->loaded()) *base = load_baseline(dataset_dir);
const SparseBaseline& b = *base;
std::vector<std::string> written;
const std::set<std::string> drop = leaf_set(keep.drop_images);
switch (b.format) {
case SparseFormat::Colmap: {
const fs::path model(b.model_dir);
std::set<int32_t> dropped_ids;
if (!drop.empty() || moved) {
const fs::path ip = model / (b.text ? "images.txt" : "images.bin");
const std::string body =
b.text ? filter_images_txt(b.images, drop, dropped_ids, moved)
: filter_images_bin(b.images, drop, dropped_ids, moved);
keep_original(ip);
write_file(ip, body);
written.push_back(ip.string());
}
// COLMAP itself reads rig_from_world in preference to the image's
// own pose, so a model that has the file has to have it moved.
if (moved && !b.frames.empty()) {
const std::string body = move_frames_bin(b.frames, *moved);
if (!body.empty()) {
const fs::path fp = model / "frames.bin";
keep_original(fp);
write_file(fp, body);
written.push_back(fp.string());
}
}
const fs::path pp = model / (b.text ? "points3D.txt" : "points3D.bin");
keep_original(pp);
write_file(pp, b.text ? filter_points3d_txt(b.points, keep.points,
dropped_ids, moved)
: filter_points3d_bin(b.points, keep.points,
dropped_ids, moved));
written.push_back(pp.string());
break;
}
case SparseFormat::Nerfstudio:
case SparseFormat::Metashape: {
// A Metashape export is not ours to rewrite: the edit lands beside
// it as the Nerfstudio dataset the parser reads first from then on.
const bool ours = b.format == SparseFormat::Nerfstudio;
JsonValue meta = json_parse(b.meta);
std::string rel = ours ? b.ply_rel : std::string("points3D_edited.ply");
if (rel.empty()) rel = "points3D.ply";
Sim3 in_json;
if (moved) in_json = to_json_frame(meta, *moved);
const fs::path ply = fs::path(dataset_dir) / rel;
if (ours) keep_original(ply);
write_ply_points(ply.string(), b.cloud.xyz.data(),
b.cloud.rgb.empty() ? nullptr : b.cloud.rgb.data(),
b.cloud.num(), keep.points.data(),
moved ? &in_json : nullptr);
written.push_back(ply.string());
drop_frames(meta, drop);
set_string(meta, "ply_file_path", "points3D_edited.ply");
if (moved) move_frames(meta, in_json);
set_string(meta, "ply_file_path", rel);
const fs::path meta_path = fs::path(dataset_dir) / "transforms.json";
if (ours) keep_original(meta_path);
JsonWriter w;
json_write(w, meta);
write_file(meta_path, w.str());
+25 -4
View File
@@ -8,6 +8,9 @@
// ours to rewrite, so the edit lands beside it as a Nerfstudio dataset, which
// parse_dataset reads first from then on.
#include "core/Similarity.h"
#include "data/DatasetParser.h"
#include <cstdint>
#include <string>
#include <vector>
@@ -32,13 +35,31 @@ struct SparseKeep {
std::vector<std::string> drop_images; // file names, any path prefix
};
// Rewrite the reconstruction under `dataset_dir`. Each replaced file is
// copied to `<name>.orig` first, once. Returns what was written.
// The files as one editing session first found them. `keep` indexes THEIR
// rows and `moved` starts from THEIR poses, so a second save has to filter
// these again rather than the files the first save left behind.
struct SparseBaseline {
SparseFormat format = SparseFormat::None;
bool text = false; // COLMAP: the .txt spelling
std::string model_dir; // COLMAP
std::string images, points, frames;
std::string meta; // Nerfstudio / Metashape: the JSON text
std::string ply_rel;
ColmapPoints3D cloud;
bool loaded() const { return format != SparseFormat::None; }
};
// Rewrite the reconstruction, copying each replaced file to `<name>.orig`
// once. `moved` is in ParsedDataset's RAW frame (COLMAP's world; a
// transforms.json with applied_transform undone). `base` null re-reads disk.
std::vector<std::string> sparse_write_filtered(const std::string& dataset_dir,
const SparseKeep& keep);
const SparseKeep& keep,
const Sim3* moved = nullptr,
SparseBaseline* base = nullptr);
// A plain point cloud, for anything that has no reconstruction behind it.
void write_ply_points(const std::string& path, const double* xyz,
const uint8_t* rgb, int64_t n, const uint8_t* keep);
const uint8_t* rgb, int64_t n, const uint8_t* keep,
const Sim3* moved = nullptr);
} // namespace spirula
+306
View File
@@ -0,0 +1,306 @@
// sparse_transform_test -- moving a reconstruction (data/SparseEdit.h) and
// reading it back. For each format the re-parsed cameras and points must be
// the originals under the same similarity, in the frame the PARSER hands
// out -- which for a transforms.json is not the frame the file is written in
// (applied_transform) -- and every point must still project to the pixel it
// did before, which is the one thing a placement may never change.
#include "data/DatasetParser.h"
#include "data/SparseEdit.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <random>
#include <string>
#include <vector>
namespace fs = std::filesystem;
using spirula::Sim3;
namespace {
int g_failures = 0;
void check(bool ok, const std::string& what) {
std::printf("%s %s\n", ok ? "ok " : "FAIL", what.c_str());
if (!ok) g_failures++;
}
template <typename T>
void put(std::string& o, T v) {
o.append(reinterpret_cast<const char*>(&v), sizeof v);
}
struct Cam { double q[4], t[3]; std::string name; };
void quat_to_R(const double q[4], double R[9]) {
const double w = q[0], x = q[1], y = q[2], z = q[3];
const double M[9] = {1-2*(y*y+z*z), 2*(x*y-z*w), 2*(x*z+y*w),
2*(x*y+z*w), 1-2*(x*x+z*z), 2*(y*z-x*w),
2*(x*z-y*w), 2*(y*z+x*w), 1-2*(x*x+y*y)};
for (int i = 0; i < 9; i++) R[i] = M[i];
}
void write_colmap(const fs::path& model, const std::vector<Cam>& cams,
const std::vector<double>& pts, bool text) {
fs::create_directories(model);
if (text) {
std::ofstream c(model / "cameras.txt");
c << "# cameras\n1 PINHOLE 640 480 500 500 320 240\n";
std::ofstream im(model / "images.txt");
im << "# images\n";
for (size_t i = 0; i < cams.size(); i++) {
char buf[512];
std::snprintf(buf, sizeof buf, "%zu %.17g %.17g %.17g %.17g %.17g %.17g %.17g 1 %s\n",
i + 1, cams[i].q[0], cams[i].q[1], cams[i].q[2],
cams[i].q[3], cams[i].t[0], cams[i].t[1], cams[i].t[2],
cams[i].name.c_str());
im << buf << "10.5 20.5 " << (i + 1) << "\n";
}
std::ofstream p(model / "points3D.txt");
p << "# points\n";
for (size_t i = 0; i < pts.size() / 3; i++) {
char buf[256];
std::snprintf(buf, sizeof buf, "%zu %.17g %.17g %.17g 10 20 30 0.5 1 0 2 0\n",
i + 1, pts[i*3], pts[i*3+1], pts[i*3+2]);
p << buf;
}
return;
}
std::string c;
put<uint64_t>(c, 1);
put<int32_t>(c, 1); put<int32_t>(c, 1); // id, PINHOLE
put<uint64_t>(c, 640); put<uint64_t>(c, 480);
for (double v : {500.0, 500.0, 320.0, 240.0}) put<double>(c, v);
std::ofstream(model / "cameras.bin", std::ios::binary) << c;
std::string im;
put<uint64_t>(im, cams.size());
for (size_t i = 0; i < cams.size(); i++) {
put<int32_t>(im, (int32_t)i + 1);
for (double v : cams[i].q) put<double>(im, v);
for (double v : cams[i].t) put<double>(im, v);
put<int32_t>(im, 1);
im += cams[i].name;
im.push_back('\0');
put<uint64_t>(im, 1);
put<double>(im, 10.5); put<double>(im, 20.5); put<int64_t>(im, (int64_t)i + 1);
}
std::ofstream(model / "images.bin", std::ios::binary) << im;
std::string p;
put<uint64_t>(p, pts.size() / 3);
for (size_t i = 0; i < pts.size() / 3; i++) {
put<uint64_t>(p, i + 1);
for (int k = 0; k < 3; k++) put<double>(p, pts[i*3+k]);
p.push_back(10); p.push_back(20); p.push_back(30);
put<double>(p, 0.5);
put<uint64_t>(p, 2);
put<int32_t>(p, 1); put<int32_t>(p, 0);
put<int32_t>(p, 2); put<int32_t>(p, 0);
}
std::ofstream(model / "points3D.bin", std::ios::binary) << p;
}
void write_nerfstudio(const fs::path& dir, const std::vector<Cam>& cams,
const std::vector<double>& pts, const double A[12]) {
fs::create_directories(dir);
// json = A * raw, for the poses and for the cloud.
std::ofstream j(dir / "transforms.json");
j.precision(17);
j << "{\n \"camera_model\": \"PINHOLE\", \"w\": 640, \"h\": 480,\n"
" \"fl_x\": 500, \"fl_y\": 500, \"cx\": 320, \"cy\": 240,\n"
" \"applied_transform\": [";
for (int r = 0; r < 3; r++) {
j << (r ? ", [" : "[");
for (int c = 0; c < 4; c++) j << (c ? ", " : "") << A[r*4+c];
j << "]";
}
j << "],\n \"ply_file_path\": \"cloud.ply\",\n \"frames\": [\n";
for (size_t i = 0; i < cams.size(); i++) {
// COLMAP w2c -> OpenGL c2w in the raw frame, then into the json one.
double R[9];
quat_to_R(cams[i].q, R);
double c2w[12];
for (int r = 0; r < 3; r++) {
c2w[r*4+0] = R[0*3+r];
c2w[r*4+1] = -R[1*3+r];
c2w[r*4+2] = -R[2*3+r];
c2w[r*4+3] = -(R[0*3+r]*cams[i].t[0] + R[1*3+r]*cams[i].t[1] +
R[2*3+r]*cams[i].t[2]);
}
double m[12];
for (int r = 0; r < 3; r++)
for (int c = 0; c < 4; c++)
m[r*4+c] = A[r*4+0]*c2w[0*4+c] + A[r*4+1]*c2w[1*4+c] +
A[r*4+2]*c2w[2*4+c] + (c == 3 ? A[r*4+3] : 0.0);
j << " {\"file_path\": \"images/" << cams[i].name
<< "\", \"transform_matrix\": [";
for (int r = 0; r < 3; r++) {
j << "[" << m[r*4] << ", " << m[r*4+1] << ", " << m[r*4+2] << ", "
<< m[r*4+3] << "], ";
}
j << "[0, 0, 0, 1]]}" << (i + 1 < cams.size() ? ",\n" : "\n");
}
j << " ]\n}\n";
std::vector<double> q(pts.size());
for (size_t i = 0; i < pts.size() / 3; i++)
for (int r = 0; r < 3; r++)
q[i*3+r] = A[r*4+0]*pts[i*3] + A[r*4+1]*pts[i*3+1] +
A[r*4+2]*pts[i*3+2] + A[r*4+3];
spirula::write_ply_points((dir / "cloud.ply").string(), q.data(), nullptr,
(int64_t)q.size() / 3, nullptr);
}
ParsedDataset parse(const fs::path& dir) {
DatasetParserConfig cfg;
cfg.require_image_files = false;
return parse_dataset(dir.string(), cfg, "");
}
// Pixel of point `p` in camera `i` (OpenGL c2w: the camera looks down -z).
bool pixel(const ParsedDataset& ds, int64_t i, const double p[3], double uv[2]) {
const float* m = &ds.c2w[(size_t)i * 12];
double d[3], c[3];
for (int r = 0; r < 3; r++) d[r] = p[r] - m[r*4+3];
for (int k = 0; k < 3; k++)
c[k] = m[0*4+k]*d[0] + m[1*4+k]*d[1] + m[2*4+k]*d[2];
if (c[2] > -1e-9) return false;
uv[0] = 500.0 * c[0] / -c[2];
uv[1] = 500.0 * -c[1] / -c[2];
return true;
}
// Compare `after` with `before` moved by T. Returns the worst errors.
void compare(const ParsedDataset& before, const ParsedDataset& after,
const Sim3& T, const std::vector<int64_t>& cam_of,
const std::vector<int64_t>& pt_of, const std::string& tag) {
double worst_p = 0, worst_c = 0, worst_r = 0, worst_uv = 0;
bool counts = after.num_cameras == (int64_t)cam_of.size() &&
after.points.num() == (int64_t)pt_of.size();
check(counts, tag + ": the right cameras and points came back");
if (!counts) return;
for (size_t k = 0; k < pt_of.size(); k++) {
double want[3];
T.apply(&before.points.xyz[(size_t)pt_of[k] * 3], want);
for (int r = 0; r < 3; r++)
worst_p = std::max(worst_p,
std::fabs(after.points.xyz[k*3+r] - want[r]));
}
for (size_t k = 0; k < cam_of.size(); k++) {
const float* a = &before.c2w[(size_t)cam_of[k] * 12];
const float* b = &after.c2w[k * 12];
const double pos[3] = {a[3], a[7], a[11]};
double want[3];
T.apply(pos, want);
for (int r = 0; r < 3; r++)
worst_c = std::max(worst_c, std::fabs(b[r*4+3] - want[r]));
for (int c = 0; c < 3; c++) {
const double v[3] = {a[0*4+c], a[1*4+c], a[2*4+c]};
double w[3];
T.rotate(v, w);
for (int r = 0; r < 3; r++)
worst_r = std::max(worst_r, std::fabs(b[r*4+c] - w[r]));
}
for (size_t j = 0; j < pt_of.size(); j++) {
double u0[2], u1[2];
const bool v0 = pixel(before, cam_of[k],
&before.points.xyz[(size_t)pt_of[j] * 3], u0);
const bool v1 = pixel(after, (int64_t)k, &after.points.xyz[j * 3], u1);
if (v0 != v1) { worst_uv = 1e9; continue; }
if (!v0) continue;
worst_uv = std::max({worst_uv, std::fabs(u0[0] - u1[0]),
std::fabs(u0[1] - u1[1])});
}
}
std::printf(" %s: point %.2e camera %.2e axes %.2e pixel %.2e\n",
tag.c_str(), worst_p, worst_c, worst_r, worst_uv);
check(worst_p < 2e-5, tag + ": points moved by T");
check(worst_c < 2e-5, tag + ": camera centres moved by T");
check(worst_r < 2e-6, tag + ": camera axes turned, not scaled");
check(worst_uv < 5e-3, tag + ": every point still lands on its pixel");
}
} // namespace
int main() {
std::mt19937 rng(5);
std::normal_distribution<double> g(0.0, 1.0);
std::vector<Cam> cams(6);
for (size_t i = 0; i < cams.size(); i++) {
double n = 0;
// Near the identity, so the scene below stays in front of them.
cams[i].q[0] = 1.0;
for (int k = 1; k < 4; k++) cams[i].q[k] = 0.15 * g(rng);
for (double v : cams[i].q) n += v * v;
for (double& v : cams[i].q) v /= std::sqrt(n);
for (int k = 0; k < 3; k++) cams[i].t[k] = 0.5 * g(rng);
cams[i].t[2] += 6.0;
cams[i].name = "img_" + std::to_string(i) + ".jpg";
}
std::vector<double> pts(40 * 3);
for (double& v : pts) v = g(rng);
const double axis[3] = {0.6, 0.48, -0.64}, pivot[3] = {0.5, -1.0, 2.0};
Sim3 T = Sim3::rotation_about(axis, 0.9, pivot);
T.s = 2.5;
T.t[0] += 3.0; T.t[1] -= 1.5; T.t[2] += 0.75;
const double axis2[3] = {0, 0, 1}, origin[3] = {0, 0, 0};
Sim3 T2 = Sim3::rotation_about(axis2, -0.4, origin);
T2.s = 0.5;
const fs::path root = fs::temp_directory_path() / "ss_sparse_transform_test";
std::error_code ec;
fs::remove_all(root, ec);
std::vector<int64_t> all_cams, all_pts;
for (int64_t i = 0; i < (int64_t)cams.size(); i++) all_cams.push_back(i);
for (int64_t i = 0; i < (int64_t)pts.size() / 3; i++) all_pts.push_back(i);
spirula::SparseKeep keep_all;
keep_all.points.assign(pts.size() / 3, 1);
for (bool text : {false, true}) {
const std::string tag = text ? "COLMAP text" : "COLMAP binary";
const fs::path dir = root / (text ? "colmap_txt" : "colmap_bin");
write_colmap(dir / "sparse" / "0", cams, pts, text);
const ParsedDataset before = parse(dir);
spirula::SparseBaseline base;
spirula::sparse_write_filtered(dir.string(), keep_all, &T, &base);
compare(before, parse(dir), T, all_cams, all_pts, tag);
// A second save of the same session starts from the same baseline:
// T2 of the original, not T2 of what the first save wrote -- and the
// keep flags still index the original rows.
spirula::SparseKeep some = keep_all;
some.points[3] = some.points[17] = 0;
some.drop_images.push_back("img_2.jpg");
spirula::sparse_write_filtered(dir.string(), some, &T2, &base);
std::vector<int64_t> c2, p2;
for (int64_t i : all_cams) if (i != 2) c2.push_back(i);
for (int64_t i : all_pts) if (i != 3 && i != 17) p2.push_back(i);
compare(before, parse(dir), T2, c2, p2, tag + ", second save");
}
{
// Nerfstudio's own COLMAP convention plus a shift, so a transform that
// forgot the conjugation cannot pass by luck.
const double A[12] = {0, 1, 0, 0.7, 1, 0, 0, -1.2, 0, 0, -1, 0.4};
const fs::path dir = root / "nerfstudio";
write_nerfstudio(dir, cams, pts, A);
const ParsedDataset before = parse(dir);
spirula::SparseBaseline base;
spirula::sparse_write_filtered(dir.string(), keep_all, &T, &base);
compare(before, parse(dir), T, all_cams, all_pts, "Nerfstudio");
spirula::sparse_write_filtered(dir.string(), keep_all, &T2, &base);
compare(before, parse(dir), T2, all_cams, all_pts,
"Nerfstudio, second save");
}
fs::remove_all(root, ec);
std::printf("%s\n", g_failures ? "FAILED" : "all passed");
return g_failures ? 1 : 0;
}
+831
View File
@@ -0,0 +1,831 @@
#pragma once
// Selecting by what an element IS rather than where it is: the attribute
// histogram and the colour sampler (app/gui/edit/Attributes.h).
//
// Attribute names are nouns that stand alone in a combo and inside the
// history line "Select by {0}: {1} to {2}", so they are written to read
// correctly in both places.
#include "i18n/BeginCatalog.h"
namespace spirula {
namespace i18n {
namespace msg {
namespace attr {
// ===========================================================================
// Selecting by attribute
// ===========================================================================
SS_MSG(sec_attribute,
EN("Select by attribute"),
JA("属性で選択"),
ZH_HANS("按属性选择"),
ZH_HANT("依屬性選取"),
KO("속성으로 선택"),
DE("Nach Eigenschaft auswählen"),
FR("Sélection par attribut"),
ES("Seleccionar por atributo"),
PT("Selecionar por atributo"),
IT("Seleziona per attributo"),
NL("Op eigenschap selecteren"),
RU("Выделение по признаку"),
TR("Özniteliğe göre seç"));
SS_MSG(none_here,
EN("Nothing to measure on this layer."),
JA("このレイヤーには測れる属性がありません。"),
ZH_HANS("这个图层上没有可测量的属性。"),
ZH_HANT("這個圖層上沒有可測量的屬性。"),
KO("이 레이어에는 측정할 속성이 없습니다."),
DE("Auf dieser Ebene gibt es nichts zu messen."),
FR("Rien à mesurer sur ce calque."),
ES("No hay nada que medir en esta capa."),
PT("Não há nada para medir nesta camada."),
IT("Niente da misurare su questo livello."),
NL("Op deze laag valt niets te meten."),
RU("На этом слое нечего измерять."),
TR("Bu katmanda ölçülecek bir şey yok."));
SS_MSG(range_hint,
EN("Drag across the plot to select a range. Drag an edge to adjust it. Dragging to the end of the plot includes everything beyond it."),
JA("グラフ上をドラッグして範囲を選びます。端をドラッグすると調整できます。グラフの端までドラッグすると、その先もすべて含まれます。"),
ZH_HANS("在图上拖动以选择一个范围,拖动边缘可以调整。拖到图的尽头表示把更远的部分也包括进来。"),
ZH_HANT("在圖上拖曳以選取一個範圍,拖曳邊緣可以調整。拖到圖的盡頭表示把更遠的部分也包括進來。"),
KO("그래프 위를 드래그해 범위를 선택하세요. 가장자리를 드래그하면 조정됩니다. 그래프 끝까지 드래그하면 그 너머도 모두 포함됩니다."),
DE("Über das Diagramm ziehen, um einen Bereich zu wählen; eine Kante ziehen, um ihn anzupassen. Bis zum Rand gezogen schließt er alles dahinter ein."),
FR("Faites glisser sur le graphique pour choisir un intervalle, ou un bord pour l'ajuster. Glisser jusqu'au bout du graphique inclut tout ce qui se trouve au-delà."),
ES("Arrastra sobre la gráfica para elegir un intervalo, o un borde para ajustarlo. Arrastrar hasta el extremo incluye todo lo que queda más allá."),
PT("Arraste sobre o gráfico para escolher um intervalo, ou uma borda para ajustá-lo. Arrastar até o fim do gráfico inclui tudo o que está além."),
IT("Trascina sul grafico per scegliere un intervallo, o un bordo per regolarlo. Trascinando fino all'estremità si include tutto ciò che sta oltre."),
NL("Sleep over de grafiek om een bereik te kiezen, of sleep een rand om het aan te passen. Slepen tot het eind van de grafiek neemt alles daarachter mee."),
RU("Проведите по графику, чтобы выбрать диапазон; потяните за край, чтобы его изменить. Если довести до конца графика, в диапазон войдёт и всё, что дальше."),
TR("Bir aralık seçmek için grafiğin üzerinde sürükleyin; ayarlamak için bir kenarı sürükleyin. Grafiğin ucuna kadar sürüklemek ötesindeki her şeyi de içerir."));
SS_MSG(range_outside,
EN("Outside"),
JA("範囲の外"),
ZH_HANS("范围之外"),
ZH_HANT("範圍之外"),
KO("범위 밖"),
DE("Außerhalb"),
FR("En dehors"),
ES("Fuera"),
PT("Fora"),
IT("Fuori"),
NL("Buiten"),
RU("Вне диапазона"),
TR("Dışında"));
SS_MSG(range_outside_help,
EN("Select everything outside the range instead. For hue, this is also how a range runs through red."),
JA("範囲の外側をすべて選択します。色相では、赤をまたぐ範囲を選ぶのにも使います。"),
ZH_HANS("改为选择范围之外的全部内容。对色相来说,这也是选取跨过红色的范围的办法。"),
ZH_HANT("改為選取範圍之外的全部內容。對色相來說,這也是選取跨過紅色的範圍的辦法。"),
KO("범위 밖의 모든 것을 대신 선택합니다. 색상에서는 빨강을 가로지르는 범위를 고르는 방법이기도 합니다."),
DE("Stattdessen alles außerhalb des Bereichs auswählen. Beim Farbton lässt sich so auch ein Bereich wählen, der durch Rot verläuft."),
FR("Sélectionner plutôt tout ce qui est hors de l'intervalle. Pour la teinte, c'est aussi la façon de choisir un intervalle qui passe par le rouge."),
ES("Selecciona en su lugar todo lo que queda fuera del intervalo. Para el tono, es también la forma de elegir un intervalo que pasa por el rojo."),
PT("Seleciona em vez disso tudo o que está fora do intervalo. Para a matiz, é também a forma de escolher um intervalo que passa pelo vermelho."),
IT("Seleziona invece tutto ciò che è fuori dall'intervallo. Per la tonalità è anche il modo di scegliere un intervallo che attraversa il rosso."),
NL("Selecteer in plaats daarvan alles buiten het bereik. Bij tint is dit ook de manier om een bereik te kiezen dat door rood loopt."),
RU("Выделить всё, что вне диапазона. Для цветового тона это ещё и способ выбрать диапазон, проходящий через красный."),
TR("Bunun yerine aralığın dışındaki her şeyi seçer. Renk tonunda, kırmızının içinden geçen bir aralık seçmenin yolu da budur."));
SS_MSG(log_counts,
EN("Log height"),
JA("対数の高さ"),
ZH_HANS("对数高度"),
ZH_HANT("對數高度"),
KO("로그 높이"),
DE("Log-Höhe"),
FR("Hauteur log"),
ES("Altura log"),
PT("Altura log"),
IT("Altezza log"),
NL("Log-hoogte"),
RU("Лог. высота"),
TR("Log yükseklik"));
SS_MSG(log_counts_help,
EN("Draw the bar heights on a logarithmic scale, so a few outliers stay visible next to a tall peak."),
JA("棒の高さを対数目盛りで描きます。高い山のそばにある少数の外れ値も見えるようになります。"),
ZH_HANS("用对数刻度绘制柱高,这样在高峰旁边的少数离群值也能看见。"),
ZH_HANT("用對數刻度繪製柱高,這樣在高峰旁邊的少數離群值也能看見。"),
KO("막대 높이를 로그 눈금으로 그려, 높은 봉우리 옆의 소수 이상값도 보이게 합니다."),
DE("Zeichnet die Balkenhöhen logarithmisch, damit wenige Ausreißer neben einer hohen Spitze sichtbar bleiben."),
FR("Trace la hauteur des barres sur une échelle logarithmique, pour que quelques valeurs aberrantes restent visibles à côté d'un grand pic."),
ES("Dibuja la altura de las barras en escala logarítmica, para que unos pocos valores atípicos sigan viéndose junto a un pico alto."),
PT("Desenha a altura das barras em escala logarítmica, para que poucos valores atípicos continuem visíveis ao lado de um pico alto."),
IT("Disegna l'altezza delle barre in scala logaritmica, così pochi valori anomali restano visibili accanto a un picco alto."),
NL("Tekent de staafhoogten op een logaritmische schaal, zodat een paar uitschieters zichtbaar blijven naast een hoge piek."),
RU("Рисует высоту столбцов в логарифмическом масштабе, чтобы немногие выбросы оставались видны рядом с высоким пиком."),
TR("Çubuk yüksekliklerini logaritmik ölçekte çizer; böylece yüksek bir tepenin yanındaki birkaç aykırı değer görünür kalır."));
SS_MSG(op_select_by,
EN("Select by {0}: {1} to {2}"),
JA("{0} で選択: {1} ~ {2}"),
ZH_HANS("按{0}选择:{1} 至 {2}"),
ZH_HANT("依{0}選取:{1} 至 {2}"),
KO("{0}(으)로 선택: {1} ~ {2}"),
DE("Auswahl nach {0}: {1} bis {2}"),
FR("Sélection par {0} : de {1} à {2}"),
ES("Selección por {0}: de {1} a {2}"),
PT("Seleção por {0}: de {1} a {2}"),
IT("Selezione per {0}: da {1} a {2}"),
NL("Selectie op {0}: {1} tot {2}"),
RU("Выделение по признаку «{0}»: от {1} до {2}"),
TR("{0} ile seçim: {1} - {2}"));
// ===========================================================================
// The attributes
// ===========================================================================
SS_MSG(a_pos_x,
EN("Position X"),
JA("位置 X"),
ZH_HANS("位置 X"),
ZH_HANT("位置 X"),
KO("위치 X"),
DE("Position X"),
FR("Position X"),
ES("Posición X"),
PT("Posição X"),
IT("Posizione X"),
NL("Positie X"),
RU("Положение X"),
TR("Konum X"));
SS_MSG(a_pos_y,
EN("Position Y"),
JA("位置 Y"),
ZH_HANS("位置 Y"),
ZH_HANT("位置 Y"),
KO("위치 Y"),
DE("Position Y"),
FR("Position Y"),
ES("Posición Y"),
PT("Posição Y"),
IT("Posizione Y"),
NL("Positie Y"),
RU("Положение Y"),
TR("Konum Y"));
SS_MSG(a_pos_z,
EN("Position Z (height)"),
JA("位置 Z(高さ)"),
ZH_HANS("位置 Z(高度)"),
ZH_HANT("位置 Z(高度)"),
KO("위치 Z(높이)"),
DE("Position Z (Höhe)"),
FR("Position Z (hauteur)"),
ES("Posición Z (altura)"),
PT("Posição Z (altura)"),
IT("Posizione Z (altezza)"),
NL("Positie Z (hoogte)"),
RU("Положение Z (высота)"),
TR("Konum Z (yükseklik)"));
SS_MSG(a_pos_help,
EN("Where the element is, in the coordinates the file will be saved in. After aligning the ground, Z is the height above it."),
JA("保存されるファイルの座標での要素の位置です。地面を整列したあとは、Z が地面からの高さになります。"),
ZH_HANS("元素的位置,以文件保存时的坐标表示。对齐地面之后,Z 就是离地面的高度。"),
ZH_HANT("元素的位置,以檔案儲存時的座標表示。對齊地面之後,Z 就是離地面的高度。"),
KO("저장될 파일의 좌표로 나타낸 요소의 위치입니다. 지면을 정렬한 뒤에는 Z가 지면 위 높이가 됩니다."),
DE("Wo das Element liegt, in den Koordinaten, in denen die Datei gespeichert wird. Nach dem Ausrichten des Bodens ist Z die Höhe darüber."),
FR("Position de l'élément, dans les coordonnées où le fichier sera enregistré. Une fois le sol aligné, Z est la hauteur au-dessus de lui."),
ES("Dónde está el elemento, en las coordenadas en las que se guardará el archivo. Tras alinear el suelo, Z es la altura sobre él."),
PT("Onde o elemento está, nas coordenadas em que o arquivo será salvo. Depois de alinhar o chão, Z é a altura acima dele."),
IT("Dove si trova l'elemento, nelle coordinate in cui il file verrà salvato. Dopo aver allineato il terreno, Z è l'altezza sopra di esso."),
NL("Waar het element ligt, in de coördinaten waarin het bestand wordt opgeslagen. Na het uitlijnen van de grond is Z de hoogte erboven."),
RU("Где находится элемент, в координатах, в которых будет сохранён файл. После выравнивания земли Z — это высота над ней."),
TR("Öğenin, dosyanın kaydedileceği koordinatlardaki yeri. Zemin hizalandıktan sonra Z, zeminden yüksekliktir."));
SS_MSG(a_opacity,
EN("Opacity"),
JA("不透明度"),
ZH_HANS("不透明度"),
ZH_HANT("不透明度"),
KO("불투명도"),
DE("Deckkraft"),
FR("Opacité"),
ES("Opacidad"),
PT("Opacidade"),
IT("Opacità"),
NL("Dekking"),
RU("Непрозрачность"),
TR("Opaklık"));
SS_MSG(a_opacity_help,
EN("How opaque the Gaussian is, from 0 to 1. Faint ones are often leftover haze."),
JA("ガウシアンの不透明度(0 から 1)です。薄いものは残ったもやであることがよくあります。"),
ZH_HANS("高斯的不透明程度,从 0 到 1。很淡的往往是残留的雾状噪点。"),
ZH_HANT("高斯的不透明程度,從 0 到 1。很淡的往往是殘留的霧狀雜訊。"),
KO("가우시안의 불투명한 정도(0~1)입니다. 옅은 것은 남은 안개인 경우가 많습니다."),
DE("Wie deckend der Gaussian ist, von 0 bis 1. Schwache sind oft übrig gebliebener Dunst."),
FR("Opacité de la gaussienne, de 0 à 1. Les plus faibles sont souvent un reste de brume."),
ES("Cuán opaca es la gaussiana, de 0 a 1. Las tenues suelen ser restos de neblina."),
PT("Quão opaca é a gaussiana, de 0 a 1. As tênues costumam ser restos de névoa."),
IT("Quanto è opaca la gaussiana, da 0 a 1. Quelle tenui sono spesso foschia residua."),
NL("Hoe dekkend de Gaussian is, van 0 tot 1. Vage exemplaren zijn vaak overgebleven waas."),
RU("Насколько гауссиан непрозрачен, от 0 до 1. Бледные часто оказываются остатками дымки."),
TR("Gauss'un ne kadar opak olduğu, 0 ile 1 arasında. Soluk olanlar çoğu zaman kalıntı pustur."));
SS_MSG(a_scale_max,
EN("Largest scale"),
JA("最大スケール"),
ZH_HANS("最大尺度"),
ZH_HANT("最大尺度"),
KO("가장 큰 스케일"),
DE("Größte Skalierung"),
FR("Plus grande échelle"),
ES("Escala mayor"),
PT("Maior escala"),
IT("Scala maggiore"),
NL("Grootste schaal"),
RU("Наибольший масштаб"),
TR("En büyük ölçek"));
SS_MSG(a_scale_min,
EN("Smallest scale"),
JA("最小スケール"),
ZH_HANS("最小尺度"),
ZH_HANT("最小尺度"),
KO("가장 작은 스케일"),
DE("Kleinste Skalierung"),
FR("Plus petite échelle"),
ES("Escala menor"),
PT("Menor escala"),
IT("Scala minore"),
NL("Kleinste schaal"),
RU("Наименьший масштаб"),
TR("En küçük ölçek"));
SS_MSG(a_scale_mean,
EN("Size (geometric mean of the scales)"),
JA("大きさ(スケールの幾何平均)"),
ZH_HANS("大小(各尺度的几何平均)"),
ZH_HANT("大小(各尺度的幾何平均)"),
KO("크기(스케일의 기하평균)"),
DE("Größe (geometrisches Mittel der Skalierungen)"),
FR("Taille (moyenne géométrique des échelles)"),
ES("Tamaño (media geométrica de las escalas)"),
PT("Tamanho (média geométrica das escalas)"),
IT("Dimensione (media geometrica delle scale)"),
NL("Grootte (meetkundig gemiddelde van de schalen)"),
RU("Размер (среднее геометрическое масштабов)"),
TR("Boyut (ölçeklerin geometrik ortalaması)"));
SS_MSG(a_scale_help,
EN("The Gaussian's own size along its axes, in the file's units."),
JA("ガウシアン自身の軸方向の大きさで、ファイルの単位で表します。"),
ZH_HANS("高斯沿自身各轴的大小,以文件的单位表示。"),
ZH_HANT("高斯沿自身各軸的大小,以檔案的單位表示。"),
KO("가우시안 자체의 축 방향 크기이며, 파일의 단위로 나타냅니다."),
DE("Die eigene Größe des Gaussians entlang seiner Achsen, in den Einheiten der Datei."),
FR("Taille propre de la gaussienne le long de ses axes, dans les unités du fichier."),
ES("El tamaño propio de la gaussiana a lo largo de sus ejes, en las unidades del archivo."),
PT("O tamanho próprio da gaussiana ao longo dos seus eixos, nas unidades do arquivo."),
IT("La dimensione propria della gaussiana lungo i suoi assi, nelle unità del file."),
NL("De eigen grootte van de Gaussian langs zijn assen, in de eenheden van het bestand."),
RU("Собственный размер гауссиана вдоль его осей, в единицах файла."),
TR("Gauss'un kendi eksenleri boyunca boyutu, dosyanın birimleriyle."));
SS_MSG(a_extent_max,
EN("Largest visible extent"),
JA("見える範囲の最大"),
ZH_HANS("最大可见范围"),
ZH_HANT("最大可見範圍"),
KO("가장 큰 가시 범위"),
DE("Größte sichtbare Ausdehnung"),
FR("Plus grande étendue visible"),
ES("Extensión visible mayor"),
PT("Maior extensão visível"),
IT("Estensione visibile maggiore"),
NL("Grootste zichtbare omvang"),
RU("Наибольший видимый размер"),
TR("En büyük görünür boyut"));
SS_MSG(a_extent_min,
EN("Smallest visible extent"),
JA("見える範囲の最小"),
ZH_HANS("最小可见范围"),
ZH_HANT("最小可見範圍"),
KO("가장 작은 가시 범위"),
DE("Kleinste sichtbare Ausdehnung"),
FR("Plus petite étendue visible"),
ES("Extensión visible menor"),
PT("Menor extensão visível"),
IT("Estensione visibile minore"),
NL("Kleinste zichtbare omvang"),
RU("Наименьший видимый размер"),
TR("En küçük görünür boyut"));
SS_MSG(a_extent_mean,
EN("Visible size (mean extent)"),
JA("見える大きさ(範囲の平均)"),
ZH_HANS("可见大小(平均范围)"),
ZH_HANT("可見大小(平均範圍)"),
KO("가시 크기(평균 범위)"),
DE("Sichtbare Größe (mittlere Ausdehnung)"),
FR("Taille visible (étendue moyenne)"),
ES("Tamaño visible (extensión media)"),
PT("Tamanho visível (extensão média)"),
IT("Dimensione visibile (estensione media)"),
NL("Zichtbare grootte (gemiddelde omvang)"),
RU("Видимый размер (средний)"),
TR("Görünür boyut (ortalama)"));
SS_MSG(a_extent_help,
EN("How far from its centre the Gaussian is actually drawn: its scale times sqrt(2 ln(255 x opacity)), the distance at which it fades below what the renderer keeps. A large but faint Gaussian is smaller here than its scale says."),
JA("ガウシアンが中心からどこまで実際に描かれるかです。スケールに sqrt(2 ln(255 x 不透明度)) を掛けた値で、レンダラーが切り捨てる薄さになる距離を表します。大きくても薄いガウシアンは、スケールが示すより小さくなります。"),
ZH_HANS("高斯从中心向外实际被绘制到多远:尺度乘以 sqrt(2 ln(255 x 不透明度)),也就是它淡到渲染器不再保留时的距离。又大又淡的高斯,在这里会比它的尺度显示的要小。"),
ZH_HANT("高斯從中心向外實際被繪製到多遠:尺度乘以 sqrt(2 ln(255 x 不透明度)),也就是它淡到算繪器不再保留時的距離。又大又淡的高斯,在這裡會比它的尺度顯示的要小。"),
KO("가우시안이 중심에서 실제로 어디까지 그려지는지입니다. 스케일에 sqrt(2 ln(255 x 불투명도))를 곱한 값으로, 렌더러가 버릴 만큼 옅어지는 거리입니다. 크지만 옅은 가우시안은 스케일이 말하는 것보다 여기서 더 작습니다."),
DE("Wie weit vom Zentrum der Gaussian tatsächlich gezeichnet wird: seine Skalierung mal sqrt(2 ln(255 x Deckkraft)), also der Abstand, ab dem er unter das fällt, was der Renderer behält. Ein großer, aber schwacher Gaussian ist hier kleiner, als seine Skalierung sagt."),
FR("Distance jusqu'à laquelle la gaussienne est réellement dessinée : son échelle multipliée par sqrt(2 ln(255 x opacité)), là où elle passe sous ce que le moteur de rendu conserve. Une gaussienne grande mais faible est ici plus petite que ne le dit son échelle."),
ES("Hasta dónde se dibuja realmente la gaussiana desde su centro: su escala por sqrt(2 ln(255 x opacidad)), la distancia a la que cae por debajo de lo que el renderizador conserva. Una gaussiana grande pero tenue es aquí más pequeña de lo que dice su escala."),
PT("Até onde a gaussiana é realmente desenhada a partir do centro: sua escala vezes sqrt(2 ln(255 x opacidade)), a distância em que ela cai abaixo do que o renderizador mantém. Uma gaussiana grande mas tênue é aqui menor do que a escala indica."),
IT("Fin dove la gaussiana viene realmente disegnata a partire dal centro: la sua scala per sqrt(2 ln(255 x opacità)), la distanza a cui scende sotto ciò che il renderer conserva. Una gaussiana grande ma tenue è qui più piccola di quanto dica la sua scala."),
NL("Hoe ver vanaf het midden de Gaussian werkelijk wordt getekend: zijn schaal maal sqrt(2 ln(255 x dekking)), de afstand waarop hij onder de grens van de renderer zakt. Een grote maar vage Gaussian is hier kleiner dan zijn schaal zegt."),
RU("Насколько далеко от центра гауссиан действительно рисуется: его масштаб, умноженный на sqrt(2 ln(255 x непрозрачность)), — расстояние, на котором он становится бледнее порога рендерера. Большой, но бледный гауссиан здесь меньше, чем говорит его масштаб."),
TR("Gauss'un merkezinden ne kadar uzağa gerçekten çizildiği: ölçeği çarpı sqrt(2 ln(255 x opaklık)), yani oluşturucunun tuttuğu eşiğin altına düştüğü uzaklık. Büyük ama soluk bir Gauss burada ölçeğinin söylediğinden küçüktür."));
SS_MSG(a_aniso_ratio,
EN("Anisotropy (largest / smallest scale)"),
JA("異方性(最大 / 最小スケール)"),
ZH_HANS("各向异性(最大 / 最小尺度)"),
ZH_HANT("各向異性(最大 / 最小尺度)"),
KO("이방성(최대 / 최소 스케일)"),
DE("Anisotropie (größte / kleinste Skalierung)"),
FR("Anisotropie (plus grande / plus petite échelle)"),
ES("Anisotropía (escala mayor / menor)"),
PT("Anisotropia (maior / menor escala)"),
IT("Anisotropia (scala maggiore / minore)"),
NL("Anisotropie (grootste / kleinste schaal)"),
RU("Анизотропия (наибольший / наименьший масштаб)"),
TR("Anizotropi (en büyük / en küçük ölçek)"));
SS_MSG(a_aniso_ratio_help,
EN("How stretched the Gaussian is. Needle-like ones with a very high ratio are a common artefact."),
JA("ガウシアンがどれだけ引き伸ばされているかです。比が非常に大きい針状のものは、よくあるアーティファクトです。"),
ZH_HANS("高斯被拉伸的程度。比值非常高的针状高斯是常见的瑕疵。"),
ZH_HANT("高斯被拉伸的程度。比值非常高的針狀高斯是常見的瑕疵。"),
KO("가우시안이 얼마나 늘어났는지입니다. 비율이 매우 높은 바늘 모양은 흔한 결함입니다."),
DE("Wie gestreckt der Gaussian ist. Nadelförmige mit sehr hohem Verhältnis sind ein häufiges Artefakt."),
FR("À quel point la gaussienne est étirée. Celles en forme d'aiguille, au rapport très élevé, sont un artefact courant."),
ES("Cuán estirada está la gaussiana. Las que tienen forma de aguja, con una proporción muy alta, son un artefacto habitual."),
PT("Quão esticada é a gaussiana. As em forma de agulha, com proporção muito alta, são um artefato comum."),
IT("Quanto è allungata la gaussiana. Quelle aghiformi, con un rapporto molto alto, sono un artefatto comune."),
NL("Hoe uitgerekt de Gaussian is. Naaldvormige met een zeer hoge verhouding zijn een veelvoorkomend artefact."),
RU("Насколько гауссиан вытянут. Игольчатые, с очень большим отношением, — частый артефакт."),
TR("Gauss'un ne kadar uzatılmış olduğu. Çok yüksek oranlı, iğne biçimli olanlar yaygın bir kusurdur."));
SS_MSG(a_erank,
EN("Anisotropy (effective rank)"),
JA("異方性(実効ランク)"),
ZH_HANS("各向异性(有效秩)"),
ZH_HANT("各向異性(有效秩)"),
KO("이방성(유효 랭크)"),
DE("Anisotropie (effektiver Rang)"),
FR("Anisotropie (rang effectif)"),
ES("Anisotropía (rango efectivo)"),
PT("Anisotropia (posto efetivo)"),
IT("Anisotropia (rango effettivo)"),
NL("Anisotropie (effectieve rang)"),
RU("Анизотропия (эффективный ранг)"),
TR("Anizotropi (etkin rank)"));
SS_MSG(a_erank_help,
EN("The effective rank of the Gaussian's shape: 1 for a needle, 2 for a flat disc, 3 for a ball."),
JA("ガウシアンの形の実効ランクです。針なら 1、平らな円盤なら 2、球なら 3 になります。"),
ZH_HANS("高斯形状的有效秩:针状为 1,扁平圆盘为 2,球状为 3。"),
ZH_HANT("高斯形狀的有效秩:針狀為 1,扁平圓盤為 2,球狀為 3。"),
KO("가우시안 모양의 유효 랭크입니다. 바늘은 1, 납작한 원반은 2, 공은 3입니다."),
DE("Der effektive Rang der Form des Gaussians: 1 für eine Nadel, 2 für eine flache Scheibe, 3 für eine Kugel."),
FR("Rang effectif de la forme de la gaussienne : 1 pour une aiguille, 2 pour un disque plat, 3 pour une boule."),
ES("El rango efectivo de la forma de la gaussiana: 1 para una aguja, 2 para un disco plano, 3 para una bola."),
PT("O posto efetivo da forma da gaussiana: 1 para uma agulha, 2 para um disco plano, 3 para uma bola."),
IT("Il rango effettivo della forma della gaussiana: 1 per un ago, 2 per un disco piatto, 3 per una sfera."),
NL("De effectieve rang van de vorm van de Gaussian: 1 voor een naald, 2 voor een platte schijf, 3 voor een bol."),
RU("Эффективный ранг формы гауссиана: 1 для иглы, 2 для плоского диска, 3 для шара."),
TR("Gauss biçiminin etkin rankı: iğne için 1, düz disk için 2, top için 3."));
SS_MSG(a_red,
EN("Red"),
JA("赤"),
ZH_HANS("红"),
ZH_HANT("紅"),
KO("빨강"),
DE("Rot"),
FR("Rouge"),
ES("Rojo"),
PT("Vermelho"),
IT("Rosso"),
NL("Rood"),
RU("Красный"),
TR("Kırmızı"));
SS_MSG(a_green,
EN("Green"),
JA("緑"),
ZH_HANS("绿"),
ZH_HANT("綠"),
KO("초록"),
DE("Grün"),
FR("Vert"),
ES("Verde"),
PT("Verde"),
IT("Verde"),
NL("Groen"),
RU("Зелёный"),
TR("Yeşil"));
SS_MSG(a_blue,
EN("Blue"),
JA("青"),
ZH_HANS("蓝"),
ZH_HANT("藍"),
KO("파랑"),
DE("Blau"),
FR("Bleu"),
ES("Azul"),
PT("Azul"),
IT("Blu"),
NL("Blauw"),
RU("Синий"),
TR("Mavi"));
SS_MSG(a_colour_help,
EN("One channel of the base colour, as displayed. Values above 1 are highlights brighter than white."),
JA("表示される基本色の 1 チャンネルです。1 を超える値は、白より明るいハイライトです。"),
ZH_HANS("显示出来的基础颜色的一个通道。大于 1 的值是比白色更亮的高光。"),
ZH_HANT("顯示出來的基礎顏色的一個通道。大於 1 的值是比白色更亮的高光。"),
KO("표시되는 기본 색의 한 채널입니다. 1보다 큰 값은 흰색보다 밝은 하이라이트입니다."),
DE("Ein Kanal der Grundfarbe, wie angezeigt. Werte über 1 sind Glanzlichter, die heller als Weiß sind."),
FR("Un canal de la couleur de base, telle qu'affichée. Les valeurs supérieures à 1 sont des hautes lumières plus claires que le blanc."),
ES("Un canal del color base, tal como se muestra. Los valores por encima de 1 son brillos más claros que el blanco."),
PT("Um canal da cor base, como exibida. Valores acima de 1 são realces mais claros que o branco."),
IT("Un canale del colore di base, così come viene mostrato. I valori sopra 1 sono alte luci più chiare del bianco."),
NL("Eén kanaal van de basiskleur, zoals weergegeven. Waarden boven 1 zijn hooglichten die helderder zijn dan wit."),
RU("Один канал базового цвета — так, как он отображается. Значения больше 1 — блики ярче белого."),
TR("Temel rengin, görüntülendiği haliyle bir kanalı. 1'in üstündeki değerler beyazdan parlak vurgulardır."));
SS_MSG(a_luma,
EN("Brightness (Y)"),
JA("明るさ (Y)"),
ZH_HANS("亮度 (Y)"),
ZH_HANT("亮度 (Y)"),
KO("밝기 (Y)"),
DE("Helligkeit (Y)"),
FR("Luminosité (Y)"),
ES("Brillo (Y)"),
PT("Brilho (Y)"),
IT("Luminosità (Y)"),
NL("Helderheid (Y)"),
RU("Яркость (Y)"),
TR("Parlaklık (Y)"));
SS_MSG(a_luma_help,
EN("How bright the base colour is (Rec. 709 luma). Values above 1 are brighter than white."),
JA("基本色の明るさ(Rec. 709 の輝度)です。1 を超える値は白より明るいことを表します。"),
ZH_HANS("基础颜色有多亮(Rec. 709 亮度)。大于 1 表示比白色更亮。"),
ZH_HANT("基礎顏色有多亮(Rec. 709 亮度)。大於 1 表示比白色更亮。"),
KO("기본 색이 얼마나 밝은지(Rec. 709 루마)입니다. 1보다 큰 값은 흰색보다 밝습니다."),
DE("Wie hell die Grundfarbe ist (Rec.-709-Luma). Werte über 1 sind heller als Weiß."),
FR("Luminosité de la couleur de base (luma Rec. 709). Les valeurs supérieures à 1 sont plus claires que le blanc."),
ES("Cuán brillante es el color base (luma Rec. 709). Los valores por encima de 1 son más claros que el blanco."),
PT("Quão brilhante é a cor base (luma Rec. 709). Valores acima de 1 são mais claros que o branco."),
IT("Quanto è luminoso il colore di base (luma Rec. 709). I valori sopra 1 sono più chiari del bianco."),
NL("Hoe helder de basiskleur is (Rec. 709-luma). Waarden boven 1 zijn helderder dan wit."),
RU("Насколько ярок базовый цвет (яркость Rec. 709). Значения больше 1 ярче белого."),
TR("Temel rengin ne kadar parlak olduğu (Rec. 709 luma). 1'in üstündeki değerler beyazdan parlaktır."));
SS_MSG(a_chroma_u,
EN("Blue-yellow (U)"),
JA("青-黄 (U)"),
ZH_HANS("蓝-黄 (U)"),
ZH_HANT("藍-黃 (U)"),
KO("파랑-노랑 (U)"),
DE("Blau-Gelb (U)"),
FR("Bleu-jaune (U)"),
ES("Azul-amarillo (U)"),
PT("Azul-amarelo (U)"),
IT("Blu-giallo (U)"),
NL("Blauw-geel (U)"),
RU("Сине-жёлтый (U)"),
TR("Mavi-sarı (U)"));
SS_MSG(a_chroma_v,
EN("Red-cyan (V)"),
JA("赤-シアン (V)"),
ZH_HANS("红-青 (V)"),
ZH_HANT("紅-青 (V)"),
KO("빨강-청록 (V)"),
DE("Rot-Cyan (V)"),
FR("Rouge-cyan (V)"),
ES("Rojo-cian (V)"),
PT("Vermelho-ciano (V)"),
IT("Rosso-ciano (V)"),
NL("Rood-cyaan (V)"),
RU("Красно-голубой (V)"),
TR("Kırmızı-camgöbeği (V)"));
SS_MSG(a_chroma_help,
EN("A colour-difference channel: brightness taken out, so the same surface in light and in shade lands in the same place."),
JA("色差のチャンネルです。明るさを取り除いてあるので、同じ面なら日なたでも日陰でも同じところに来ます。"),
ZH_HANS("色差通道:去掉了亮度,所以同一个表面无论在亮处还是暗处都落在同一位置。"),
ZH_HANT("色差通道:去掉了亮度,所以同一個表面無論在亮處還是暗處都落在同一位置。"),
KO("색차 채널입니다. 밝기를 뺐기 때문에 같은 면은 볕에서든 그늘에서든 같은 자리에 옵니다."),
DE("Ein Farbdifferenzkanal: ohne die Helligkeit, sodass dieselbe Fläche im Licht und im Schatten an derselben Stelle landet."),
FR("Un canal de différence de couleur : la luminosité en est retirée, si bien qu'une même surface, au soleil ou à l'ombre, tombe au même endroit."),
ES("Un canal de diferencia de color: sin el brillo, de modo que la misma superficie, a la luz o a la sombra, cae en el mismo sitio."),
PT("Um canal de diferença de cor: sem o brilho, de modo que a mesma superfície, na luz ou na sombra, cai no mesmo lugar."),
IT("Un canale di differenza di colore: senza la luminosità, così la stessa superficie, alla luce o in ombra, cade nello stesso punto."),
NL("Een kleurverschilkanaal: zonder de helderheid, zodat hetzelfde oppervlak in licht en in schaduw op dezelfde plek terechtkomt."),
RU("Цветоразностный канал: яркость убрана, поэтому одна и та же поверхность на свету и в тени попадает в одно место."),
TR("Bir renk farkı kanalı: parlaklık çıkarılmıştır, bu yüzden aynı yüzey ışıkta da gölgede de aynı yere düşer."));
SS_MSG(a_hue,
EN("Hue"),
JA("色相"),
ZH_HANS("色相"),
ZH_HANT("色相"),
KO("색상"),
DE("Farbton"),
FR("Teinte"),
ES("Tono"),
PT("Matiz"),
IT("Tonalità"),
NL("Tint"),
RU("Цветовой тон"),
TR("Renk tonu"));
SS_MSG(a_hue_help,
EN("The colour's angle on the colour wheel, in degrees: 0 red, 120 green, 240 blue. Greys have no hue and are left out."),
JA("色相環での色の角度(度)です。0 が赤、120 が緑、240 が青。灰色には色相がないため含まれません。"),
ZH_HANS("颜色在色轮上的角度(度):0 为红,120 为绿,240 为蓝。灰色没有色相,不计入。"),
ZH_HANT("顏色在色輪上的角度(度):0 為紅,120 為綠,240 為藍。灰色沒有色相,不計入。"),
KO("색상환에서의 각도(도)입니다. 0은 빨강, 120은 초록, 240은 파랑. 회색은 색상이 없어 제외됩니다."),
DE("Der Winkel der Farbe auf dem Farbkreis in Grad: 0 Rot, 120 Grün, 240 Blau. Grautöne haben keinen Farbton und bleiben außen vor."),
FR("Angle de la couleur sur le cercle chromatique, en degrés : 0 rouge, 120 vert, 240 bleu. Les gris n'ont pas de teinte et sont laissés de côté."),
ES("El ángulo del color en el círculo cromático, en grados: 0 rojo, 120 verde, 240 azul. Los grises no tienen tono y se dejan fuera."),
PT("O ângulo da cor no círculo cromático, em graus: 0 vermelho, 120 verde, 240 azul. Os cinzas não têm matiz e ficam de fora."),
IT("L'angolo del colore sul cerchio cromatico, in gradi: 0 rosso, 120 verde, 240 blu. I grigi non hanno tonalità e sono esclusi."),
NL("De hoek van de kleur op de kleurencirkel, in graden: 0 rood, 120 groen, 240 blauw. Grijzen hebben geen tint en tellen niet mee."),
RU("Угол цвета на цветовом круге в градусах: 0 — красный, 120 — зелёный, 240 — синий. У серых тонов тона нет, они не учитываются."),
TR("Rengin renk çemberindeki açısı, derece cinsinden: 0 kırmızı, 120 yeşil, 240 mavi. Grilerin tonu yoktur ve dışarıda bırakılır."));
SS_MSG(a_saturation,
EN("Saturation"),
JA("彩度"),
ZH_HANS("饱和度"),
ZH_HANT("飽和度"),
KO("채도"),
DE("Sättigung"),
FR("Saturation"),
ES("Saturación"),
PT("Saturação"),
IT("Saturazione"),
NL("Verzadiging"),
RU("Насыщенность"),
TR("Doygunluk"));
SS_MSG(a_saturation_help,
EN("How vivid the colour is, from 0 (grey) to 1."),
JA("色の鮮やかさです。0(灰色)から 1 まで。"),
ZH_HANS("颜色的鲜艳程度,从 0(灰色)到 1。"),
ZH_HANT("顏色的鮮豔程度,從 0(灰色)到 1。"),
KO("색이 얼마나 선명한지입니다. 0(회색)부터 1까지."),
DE("Wie kräftig die Farbe ist, von 0 (Grau) bis 1."),
FR("Vivacité de la couleur, de 0 (gris) à 1."),
ES("Cuán vivo es el color, de 0 (gris) a 1."),
PT("Quão viva é a cor, de 0 (cinza) a 1."),
IT("Quanto è vivido il colore, da 0 (grigio) a 1."),
NL("Hoe levendig de kleur is, van 0 (grijs) tot 1."),
RU("Насколько цвет насыщен, от 0 (серый) до 1."),
TR("Rengin ne kadar canlı olduğu, 0'dan (gri) 1'e."));
SS_MSG(a_camera_distance,
EN("Distance to the nearest camera"),
JA("最も近いカメラまでの距離"),
ZH_HANS("到最近相机的距离"),
ZH_HANT("到最近相機的距離"),
KO("가장 가까운 카메라까지의 거리"),
DE("Abstand zur nächsten Kamera"),
FR("Distance à la caméra la plus proche"),
ES("Distancia a la cámara más cercana"),
PT("Distância até a câmera mais próxima"),
IT("Distanza dalla fotocamera più vicina"),
NL("Afstand tot de dichtstbijzijnde camera"),
RU("Расстояние до ближайшей камеры"),
TR("En yakın kameraya uzaklık"));
SS_MSG(a_camera_distance_help,
EN("How far the point is from the closest camera. Points very far from every camera are usually poorly triangulated."),
JA("点から最も近いカメラまでの距離です。どのカメラからも非常に遠い点は、たいてい三角測量の精度が低い点です。"),
ZH_HANS("该点离最近相机的距离。离所有相机都很远的点,通常三角测量得不准。"),
ZH_HANT("該點離最近相機的距離。離所有相機都很遠的點,通常三角測量得不準。"),
KO("점에서 가장 가까운 카메라까지의 거리입니다. 모든 카메라에서 아주 먼 점은 대개 삼각측량이 부정확합니다."),
DE("Wie weit der Punkt von der nächsten Kamera entfernt ist. Punkte, die von jeder Kamera sehr weit weg sind, sind meist schlecht trianguliert."),
FR("Distance du point à la caméra la plus proche. Les points très éloignés de toutes les caméras sont en général mal triangulés."),
ES("A qué distancia está el punto de la cámara más cercana. Los puntos muy alejados de todas las cámaras suelen estar mal triangulados."),
PT("A que distância o ponto está da câmera mais próxima. Pontos muito distantes de todas as câmeras costumam estar mal triangulados."),
IT("Quanto dista il punto dalla fotocamera più vicina. I punti molto lontani da tutte le fotocamere sono di solito triangolati male."),
NL("Hoe ver het punt van de dichtstbijzijnde camera ligt. Punten die ver van alle camera's liggen zijn meestal slecht getrianguleerd."),
RU("Насколько точка удалена от ближайшей камеры. Точки, далёкие от всех камер, обычно триангулированы плохо."),
TR("Noktanın en yakın kameraya uzaklığı. Bütün kameralardan çok uzak noktalar genellikle kötü üçgenlenmiştir."));
// ===========================================================================
// Selecting by colour
// ===========================================================================
SS_MSG(sec_colour,
EN("Select by colour"),
JA("色で選択"),
ZH_HANS("按颜色选择"),
ZH_HANT("依顏色選取"),
KO("색으로 선택"),
DE("Nach Farbe auswählen"),
FR("Sélection par couleur"),
ES("Seleccionar por color"),
PT("Selecionar por cor"),
IT("Seleziona per colore"),
NL("Op kleur selecteren"),
RU("Выделение по цвету"),
TR("Renge göre seç"));
SS_MSG(tool_eyedropper,
EN("Pick a colour"),
JA("色を拾う"),
ZH_HANS("吸取颜色"),
ZH_HANT("吸取顏色"),
KO("색 추출"),
DE("Farbe aufnehmen"),
FR("Prélever une couleur"),
ES("Tomar un color"),
PT("Capturar uma cor"),
IT("Preleva un colore"),
NL("Kleur oppikken"),
RU("Взять цвет"),
TR("Renk al"));
SS_MSG(hint_eyedropper,
EN("Click the model to select everything of that colour. Shift+click adds another sample."),
JA("モデルをクリックすると、その色のものをすべて選択します。Shift+クリックでサンプルを追加します。"),
ZH_HANS("点击模型,选中所有该颜色的部分。Shift+点击可再添加一个取样。"),
ZH_HANT("點選模型,選取所有該顏色的部分。Shift+點選可再加入一個取樣。"),
KO("모델을 클릭하면 그 색인 것을 모두 선택합니다. Shift+클릭으로 샘플을 추가합니다."),
DE("Auf das Modell klicken, um alles in dieser Farbe auszuwählen. Shift+Klick fügt eine weitere Probe hinzu."),
FR("Cliquez sur le modèle pour sélectionner tout ce qui a cette couleur. Shift+clic ajoute un autre échantillon."),
ES("Haz clic en el modelo para seleccionar todo lo de ese color. Shift+clic añade otra muestra."),
PT("Clique no modelo para selecionar tudo dessa cor. Shift+clique adiciona outra amostra."),
IT("Fai clic sul modello per selezionare tutto ciò che ha quel colore. Shift+clic aggiunge un altro campione."),
NL("Klik op het model om alles met die kleur te selecteren. Shift+klik voegt nog een monster toe."),
RU("Щёлкните по модели, чтобы выделить всё этого цвета. Shift+щелчок добавляет ещё один образец."),
TR("O renkteki her şeyi seçmek için modele tıklayın. Shift+tıklama bir örnek daha ekler."));
SS_MSG(no_colour_here,
EN("This layer has no colours."),
JA("このレイヤーには色がありません。"),
ZH_HANS("这个图层没有颜色。"),
ZH_HANT("這個圖層沒有顏色。"),
KO("이 레이어에는 색이 없습니다."),
DE("Diese Ebene hat keine Farben."),
FR("Ce calque n'a pas de couleurs."),
ES("Esta capa no tiene colores."),
PT("Esta camada não tem cores."),
IT("Questo livello non ha colori."),
NL("Deze laag heeft geen kleuren."),
RU("На этом слое нет цветов."),
TR("Bu katmanda renk yok."));
SS_MSG(colour_hint,
EN("Pick a colour from the model, or add one and edit it. Right-click a sample to remove it."),
JA("モデルから色を拾うか、色を追加して編集します。サンプルを右クリックすると削除されます。"),
ZH_HANS("从模型上吸取颜色,或者添加一个颜色再编辑。右键点击取样可将其删除。"),
ZH_HANT("從模型上吸取顏色,或者加入一個顏色再編輯。右鍵點選取樣可將其移除。"),
KO("모델에서 색을 추출하거나 색을 추가해 편집하세요. 샘플을 오른쪽 클릭하면 제거됩니다."),
DE("Eine Farbe vom Modell aufnehmen oder eine hinzufügen und bearbeiten. Ein Rechtsklick auf eine Probe entfernt sie."),
FR("Prélevez une couleur sur le modèle, ou ajoutez-en une et modifiez-la. Un clic droit sur un échantillon le supprime."),
ES("Toma un color del modelo, o añade uno y edítalo. Un clic derecho sobre una muestra la elimina."),
PT("Capture uma cor do modelo, ou adicione uma e edite-a. Um clique direito em uma amostra a remove."),
IT("Preleva un colore dal modello, oppure aggiungine uno e modificalo. Un clic destro su un campione lo rimuove."),
NL("Pik een kleur op van het model, of voeg er een toe en bewerk die. Rechtsklik op een monster om het te verwijderen."),
RU("Возьмите цвет с модели или добавьте и измените свой. Щелчок правой кнопкой по образцу удаляет его."),
TR("Modelden bir renk alın ya da bir renk ekleyip düzenleyin. Bir örneğe sağ tıklamak onu kaldırır."));
SS_MSG(colour_add,
EN("Add a colour"),
JA("色を追加"),
ZH_HANS("添加颜色"),
ZH_HANT("加入顏色"),
KO("색 추가"),
DE("Farbe hinzufügen"),
FR("Ajouter une couleur"),
ES("Añadir un color"),
PT("Adicionar uma cor"),
IT("Aggiungi un colore"),
NL("Kleur toevoegen"),
RU("Добавить цвет"),
TR("Renk ekle"));
SS_MSG(colour_add_help,
EN("Add a sample to edit by hand. Everything close to any of the samples is selected."),
JA("手で編集できるサンプルを追加します。いずれかのサンプルに近い色のものがすべて選択されます。"),
ZH_HANS("添加一个可手动编辑的取样。与任一取样相近的部分都会被选中。"),
ZH_HANT("加入一個可手動編輯的取樣。與任一取樣相近的部分都會被選取。"),
KO("직접 편집할 샘플을 추가합니다. 샘플 중 하나와 가까운 색은 모두 선택됩니다."),
DE("Fügt eine Probe hinzu, die sich von Hand bearbeiten lässt. Alles, was einer der Proben nahekommt, wird ausgewählt."),
FR("Ajoute un échantillon à modifier à la main. Tout ce qui est proche de l'un des échantillons est sélectionné."),
ES("Añade una muestra para editarla a mano. Se selecciona todo lo que se parezca a cualquiera de las muestras."),
PT("Adiciona uma amostra para editar à mão. Tudo o que for próximo de qualquer uma das amostras é selecionado."),
IT("Aggiunge un campione da modificare a mano. Viene selezionato tutto ciò che è vicino a uno qualsiasi dei campioni."),
NL("Voegt een monster toe om met de hand te bewerken. Alles wat dicht bij een van de monsters ligt, wordt geselecteerd."),
RU("Добавляет образец, который можно изменить вручную. Выделяется всё, что близко к любому из образцов."),
TR("Elle düzenlenecek bir örnek ekler. Örneklerden herhangi birine yakın olan her şey seçilir."));
SS_MSG(colour_tolerance,
EN("Tolerance"),
JA("許容範囲"),
ZH_HANS("容差"),
ZH_HANT("容差"),
KO("허용 범위"),
DE("Toleranz"),
FR("Tolérance"),
ES("Tolerancia"),
PT("Tolerância"),
IT("Tolleranza"),
NL("Tolerantie"),
RU("Допуск"),
TR("Tolerans"));
SS_MSG(colour_tolerance_help,
EN("How different a colour may look and still match. Measured so that equal steps look equally different to the eye."),
JA("色がどれだけ違って見えても一致とみなすかです。同じ幅の差が目に同じだけ違って見えるような尺度で測ります。"),
ZH_HANS("颜色看起来相差多少仍算匹配。所用的度量方式让相同的差值在人眼看来差别也相同。"),
ZH_HANT("顏色看起來相差多少仍算相符。所用的度量方式讓相同的差值在人眼看來差別也相同。"),
KO("색이 얼마나 달라 보여도 일치로 볼지입니다. 같은 크기의 차이가 눈에도 같은 만큼 달라 보이도록 측정합니다."),
DE("Wie verschieden eine Farbe aussehen darf und noch passt. So gemessen, dass gleiche Schritte für das Auge gleich verschieden aussehen."),
FR("Écart d'apparence qu'une couleur peut présenter tout en correspondant. Mesuré de sorte que des écarts égaux paraissent également différents à l'œil."),
ES("Cuánto puede diferir un color a la vista y seguir coincidiendo. Se mide de modo que pasos iguales se vean igual de distintos al ojo."),
PT("Quanto uma cor pode parecer diferente e ainda corresponder. Medido de modo que passos iguais pareçam igualmente diferentes ao olho."),
IT("Quanto un colore può apparire diverso e corrispondere comunque. Misurato in modo che passi uguali appaiano ugualmente diversi all'occhio."),
NL("Hoe verschillend een kleur eruit mag zien en toch overeenkomt. Zo gemeten dat gelijke stappen er voor het oog even verschillend uitzien."),
RU("Насколько цвет может отличаться на вид и всё же считаться совпавшим. Измеряется так, чтобы равные шаги выглядели одинаково различными для глаза."),
TR("Bir rengin ne kadar farklı görünüp yine de eşleşebileceği. Eşit adımlar göze eşit derecede farklı görünecek biçimde ölçülür."));
SS_MSG(colour_lightness,
EN("Match brightness"),
JA("明るさも比べる"),
ZH_HANS("比较亮度"),
ZH_HANT("比較亮度"),
KO("밝기 비교"),
DE("Helligkeit vergleichen"),
FR("Comparer la luminosité"),
ES("Comparar el brillo"),
PT("Comparar o brilho"),
IT("Confronta la luminosità"),
NL("Helderheid meewegen"),
RU("Учитывать яркость"),
TR("Parlaklığı karşılaştır"));
SS_MSG(colour_lightness_help,
EN("How much brightness counts. At 0 only the hue and vividness are compared, so a surface matches in sun and in shade."),
JA("明るさをどれだけ考慮するかです。0 にすると色相と鮮やかさだけを比べるので、同じ面が日なたでも日陰でも一致します。"),
ZH_HANS("亮度占多大比重。设为 0 时只比较色相和鲜艳程度,因此同一表面在阳光下和阴影里都能匹配。"),
ZH_HANT("亮度占多大比重。設為 0 時只比較色相和鮮豔程度,因此同一表面在陽光下和陰影裡都能相符。"),
KO("밝기를 얼마나 반영할지입니다. 0이면 색상과 선명도만 비교하므로 같은 면이 볕에서도 그늘에서도 일치합니다."),
DE("Wie stark die Helligkeit zählt. Bei 0 werden nur Farbton und Farbkraft verglichen, sodass eine Fläche in Sonne und Schatten passt."),
FR("Poids de la luminosité. À 0, seules la teinte et la vivacité sont comparées : une surface correspond au soleil comme à l'ombre."),
ES("Cuánto cuenta el brillo. En 0 solo se comparan el tono y la viveza, de modo que una superficie coincide al sol y a la sombra."),
PT("Quanto o brilho conta. Em 0 só a matiz e a vivacidade são comparadas, de modo que uma superfície corresponde no sol e na sombra."),
IT("Quanto conta la luminosità. A 0 si confrontano solo tonalità e vivacità, così una superficie corrisponde al sole e all'ombra."),
NL("Hoe zwaar de helderheid meetelt. Bij 0 worden alleen tint en levendigheid vergeleken, zodat een oppervlak in zon en schaduw overeenkomt."),
RU("Насколько учитывается яркость. При 0 сравниваются только тон и насыщенность, так что поверхность совпадает и на солнце, и в тени."),
TR("Parlaklığın ne kadar sayıldığı. 0'da yalnızca ton ve canlılık karşılaştırılır; böylece bir yüzey güneşte de gölgede de eşleşir."));
SS_MSG(op_select_colour,
EN("Select by colour"),
JA("色で選択"),
ZH_HANS("按颜色选择"),
ZH_HANT("依顏色選取"),
KO("색으로 선택"),
DE("Auswahl nach Farbe"),
FR("Sélection par couleur"),
ES("Selección por color"),
PT("Seleção por cor"),
IT("Selezione per colore"),
NL("Selectie op kleur"),
RU("Выделение по цвету"),
TR("Renge göre seçim"));
} // namespace attr
} // namespace msg
} // namespace i18n
} // namespace spirula
#include "i18n/EndCatalog.h"
File diff suppressed because it is too large Load Diff
+81
View File
@@ -9029,6 +9029,87 @@ SS_MSG(batch_plan_mesh_run,
RU("{0}. Построить меш по прогону, обученному с {1}"),
TR("{0}. {1} ile eğitilen çalıştırmadan ağ oluştur"));
// ===========================================================================
// The navigation gizmo
// ===========================================================================
SS_MSG(gizmo_help,
EN("Drag to orbit. Click an axis to look along it; click it again for the far side."),
JA("ドラッグで視点を回転します。軸をクリックするとその軸方向から見ます。もう一度クリックすると反対側からになります。"),
ZH_HANS("拖动以环绕视角。点击某个轴可沿该轴观察,再点一次则从另一侧观察。"),
ZH_HANT("拖曳以環繞視角。點選某個軸可沿該軸觀看,再點一次則從另一側觀看。"),
KO("드래그하면 시점이 회전합니다. 축을 클릭하면 그 축 방향에서 보고, 다시 클릭하면 반대쪽에서 봅니다."),
DE("Ziehen dreht die Ansicht. Ein Klick auf eine Achse blickt entlang dieser Achse, ein zweiter Klick von der Gegenseite."),
FR("Faites glisser pour tourner autour. Cliquez sur un axe pour regarder le long de celui-ci, et une seconde fois pour le côté opposé."),
ES("Arrastra para orbitar. Haz clic en un eje para mirar a lo largo de él; otro clic para el lado opuesto."),
PT("Arraste para orbitar. Clique em um eixo para olhar ao longo dele; clique de novo para o lado oposto."),
IT("Trascina per orbitare. Fai clic su un asse per guardare lungo di esso; un altro clic per il lato opposto."),
NL("Sleep om rond het model te draaien. Klik op een as om erlangs te kijken; klik nogmaals voor de andere kant."),
RU("Перетащите, чтобы вращать вид. Щёлкните по оси, чтобы смотреть вдоль неё; ещё раз — с обратной стороны."),
TR("Yörüngede dönmek için sürükleyin. Bir eksene tıklayınca o eksen boyunca bakılır; yeniden tıklayınca karşı taraftan."));
SS_MSG(gizmo_zoom_help,
EN("Drag up or down to zoom."),
JA("上下にドラッグしてズームします。"),
ZH_HANS("上下拖动以缩放。"),
ZH_HANT("上下拖曳以縮放。"),
KO("위아래로 드래그해 확대·축소합니다."),
DE("Zum Zoomen nach oben oder unten ziehen."),
FR("Faites glisser vers le haut ou le bas pour zoomer."),
ES("Arrastra hacia arriba o abajo para acercar o alejar."),
PT("Arraste para cima ou para baixo para aproximar ou afastar."),
IT("Trascina in alto o in basso per ingrandire o ridurre."),
NL("Sleep omhoog of omlaag om te zoomen."),
RU("Перетащите вверх или вниз, чтобы приблизить или отдалить."),
TR("Yakınlaştırmak için yukarı ya da aşağı sürükleyin."));
SS_MSG(gizmo_pan_help,
EN("Drag to pan."),
JA("ドラッグして視点を平行移動します。"),
ZH_HANS("拖动以平移视角。"),
ZH_HANT("拖曳以平移視角。"),
KO("드래그해 시점을 평행 이동합니다."),
DE("Zum Verschieben der Ansicht ziehen."),
FR("Faites glisser pour déplacer la vue."),
ES("Arrastra para desplazar la vista."),
PT("Arraste para deslocar a vista."),
IT("Trascina per spostare la vista."),
NL("Sleep om het beeld te verschuiven."),
RU("Перетащите, чтобы сдвинуть вид."),
TR("Görünümü kaydırmak için sürükleyin."));
SS_MSG(gizmo_to_ortho,
EN("Switch to the orthographic view (numeric-pad 5)."),
JA("平行投影に切り替えます(テンキーの 5)。"),
ZH_HANS("切换到正交视图(数字键盘 5)。"),
ZH_HANT("切換到正交視圖(數字鍵盤 5)。"),
KO("직교 투영으로 전환합니다(숫자 패드 5)."),
DE("Zur orthografischen Ansicht wechseln (Ziffernblock 5)."),
FR("Passer à la vue orthographique (pavé numérique 5)."),
ES("Cambiar a la vista ortográfica (teclado numérico 5)."),
PT("Mudar para a vista ortográfica (teclado numérico 5)."),
IT("Passa alla vista ortografica (tastierino numerico 5)."),
NL("Overschakelen naar orthografische weergave (numeriek toetsenblok 5)."),
RU("Переключиться на ортографический вид (цифровая клавиатура 5)."),
TR("Ortografik görünüme geç (sayısal tuş takımı 5)."));
SS_MSG(gizmo_to_perspective,
EN("Switch to the perspective view (numeric-pad 5)."),
JA("透視投影に切り替えます(テンキーの 5)。"),
ZH_HANS("切换到透视视图(数字键盘 5)。"),
ZH_HANT("切換到透視視圖(數字鍵盤 5)。"),
KO("원근 투영으로 전환합니다(숫자 패드 5)."),
DE("Zur perspektivischen Ansicht wechseln (Ziffernblock 5)."),
FR("Passer à la vue en perspective (pavé numérique 5)."),
ES("Cambiar a la vista en perspectiva (teclado numérico 5)."),
PT("Mudar para a vista em perspectiva (teclado numérico 5)."),
IT("Passa alla vista prospettica (tastierino numerico 5)."),
NL("Overschakelen naar perspectiefweergave (numeriek toetsenblok 5)."),
RU("Переключиться на перспективный вид (цифровая клавиатура 5)."),
TR("Perspektif görünüme geç (sayısal tuş takımı 5)."));
} // namespace gui
} // namespace msg
} // namespace i18n