mirror of
https://github.com/harry7557558/spirula-studio.git
synced 2026-10-02 02:44:54 +08:00
editing: histogram selection + Blender-style transform
This commit is contained in:
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)++;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user