Skip to content

In-game material stage editor + editor UI overhaul#895

Open
skies912 wants to merge 182 commits into
mainfrom
feature/material-stage-editor
Open

In-game material stage editor + editor UI overhaul#895
skies912 wants to merge 182 commits into
mainfrom
feature/material-stage-editor

Conversation

@skies912

@skies912 skies912 commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

Work in Progress
Generated with Claude Code

Summary

Extends the in-game editor with live material stage editing, entity/mesh editing, unified ray selection, and a UI overhaul. Currently merged onto jdolan-sdl-gpu-renderer to track the Vulkan renderer rewrite, so the diff against main includes that branch's history pending its own merge.

Material stage editor

Add/remove/tune material stages (scroll, pulse, envmap, warp, terrain, animation, blend, …) live in-game, then Save to the .mat. Chunked/progressive stage-view construction avoids the multi-second freeze when switching to a heavily-staged material; Add/Remove Stage patch the list in place instead of a full rebuild. Per-effect editor rows, editable/validated texture paths, live-editable Blend src/dest (updated for the renderer's new cm_blend_t enum).

Entity tab

classname is the group-box title, remaining keys wrapped inside it; Team Master box shown only for light entities; Create/Delete moved onto the Entity tab; worldspawn sky/message live-editable with path validation.

Mesh tab

Enabled/disabled based on the current ray-selection (kept always-present but disabled, rather than added/removed, to avoid a tab-bar width ratchet).

Unified ray selection (#840)

One shared, nearest-first candidate list feeds all three tabs (entity def / surface material / mesh model), so the mouse wheel cycles through overlapping hits regardless of the active tab -- fixes decorative brushes (e.g. common/dust) blocking selection of what's behind them.

Editor chrome

Top menu strip, docked panel, viewport-filling panel height derived from window_bounds so it adapts to resolution.

Reusable widgets promoted upstream

Scrollbar/ScrollThumb (bound directly to a StackView's own opt-in scroll) and KeyValueView/KeyValueTableView (two-column key/value rows) were generalized and moved into ObjectivelyMVC itself -- see jdolan/ObjectivelyMVC#43, which this branch depends on.

Notes for review

  • Depends on Perf fixes + Scrollbar/KeyValueView classes ObjectivelyMVC#43.
  • Merged jdolan-sdl-gpu-renderer to track the Vulkan/SDL_gpu rewrite; adjusted the Blend dropdown + Cm_FinalizeStage for the new cm_blend_t enum, and dropped an obsolete OpenGL texture-bind workaround in r_sky.c (structurally impossible under the new texture model).
  • Per-tab CSS binding is still unreliable in ObjectivelyMVC (a tab's own stylesheet doesn't reliably style its root/descendants); some styling remains C-hardcoded pending that fix.

Testing

Builds clean (Debug, full solution incl. quetoo.exe/libui) against ObjectivelyMVC (rebased onto its current main) and jdolan-sdl-gpu-renderer. Exercised in-game: stage add/remove/tune/scroll, entity create/delete/edit, mesh tab enable/disable, ray-selection cycling across all three tabs, panel layout at multiple resolutions.

skies912 and others added 4 commits June 21, 2026 20:49
Extend the editor's Material tab to create, remove, and tune material
stages in real time, eliminating the quit/edit-.mat/relaunch cycle.

Engine:
- collision: Cm_FinalizeStage / Cm_AddStage / Cm_RemoveStage factor the
  parser's stage-finalize logic and support mutating a material's stage list.
- renderer: R_UpdateMaterialStages rebuilds the r_stage_t list (and media)
  after an add/remove; parameter/flag edits remain live with no rebuild.
- cgi: AddMaterialStage / RemoveMaterialStage / FinalizeMaterialStage imports
  (CGAME_API_VERSION bumped); two new stage notifications.

UI:
- StageView: one StackView per stage; effects added via a dropdown of
  available STAGE_* effects, each exposing its parameter sliders; per-row
  remove; click-to-select reveals a stage's Remove button.
- MaterialViewController: Material/PBR/Stages group boxes; Add Stage button;
  rebuilds the list on add/remove.

Param/flag edits write straight to cm_stage_t and render next frame; only
add/remove rebuilds the render stage list. Save persists via r_save_materials.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entity tab:
- classname is now the group-box title (not an editable row); all remaining
  keys are wrapped inside that box
- Team Master box is shown only for `light` entities
- Create/Delete Entity moved off the shared panel accessory onto the Entity tab
  (bottom-left); Save stays in the shared bar
- deleting an entity now selects worldspawn instead of clearing the selection
- fix boxes overlapping on Create by re-laying-out the container on selection
  change (Team Master toggling visibility otherwise left a stale layout)

Editor panel:
- the tab-page height is computed from the viewport
  (window_bounds.h - strip - chrome) so the panel fills the window and adapts
  to resolution, with the action bar pinned at the bottom

Global:
- group-box title font 12 -> 17 (one point over the 16pt body) so titles read
  as headers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e-editor

# Conflicts:
#	src/cgame/default/ui/editor/EntityViewController.c
EditorViewController.c includes ControlsViewController.h /
SettingsViewController.h / DialogViewController.h by bare name (matching
MainViewController.c). The Windows .vcxproj resolves these via its global UI
include path, but the editor's Makefile.am only added -I.../ui, so the Linux
build failed with "ControlsViewController.h: No such file or directory". Add
-I for ui/common, ui/controls and ui/settings, mirroring main/Makefile.am.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@jdolan

jdolan commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Oh man. The editor files in src/client/ui are all stale! I should have deleted them months ago but I guess I forgot. I'm sure those caused some confusion. You can nuke them in this PR, that would be helpful. I'd do it but I don't want to create conflicts for your branch.

- New files are located in `quetoo\src\cgame\default\ui\editor`
@skies912

skies912 commented Jun 25, 2026

Copy link
Copy Markdown
Collaborator Author

Oh man. The editor files in src/client/ui are all stale! I should have deleted them months ago but I guess I forgot. I'm sure those caused some confusion. You can nuke them in this PR, that would be helpful. I'd do it but I don't want to create conflicts for your branch.

Done. Yeah that did confuse me. I figured one was newer n you just kept the old for ref/backup or something. Forgot to ask about it.

jdolan and others added 17 commits July 1, 2026 07:20
Add PKG_CHECK_MODULES([OBJECTIVELYGPU], [ObjectivelyGPU >= 1.0.0]) and bump
the ObjectivelyMVC requirement to >= 2.0.0 (the GPU-ported release that
renders through ObjectivelyGPU). Thread @OBJECTIVELYGPU_CFLAGS@/@OBJECTIVELYGPU_LIBS@
into the renderer and ui Makefile.am.

configure passes all three checks: Objectively 2.0.9, ObjectivelyGPU 1.0.0,
ObjectivelyMVC 2.0.0.

First step of the OpenGL -> ObjectivelyGPU renderer port. Refs #864.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vet passed: Quetoo's GLSL cross-compiles GLSL -> SPIR-V (glslc) -> MSL
(shadercross), including the exotic sampler types, so we keep GLSL and rule out
an HLSL rewrite. This establishes the offline transpile pipeline and the SDL_gpu
descriptor-set slot map, and converts the shaders that need no material/lighting.

uniforms.glsl: restructured as the shared foundation. Per-stage set macros
(VERTEX_SHADER/FRAGMENT_SHADER select sampler sets 0/2 and uniform sets 1/3),
the globals uniforms_block at binding 0, and a fixed, named fragment sampler map
(shadercross preserves the binding indices through to MSL [[texture(n)]], even
when sparse). Light machinery (light_t, lights[], active_lights) relocated out
to light.glsl in Phase 5, since lights[] (~27 KB) exceeds the 16 KB uniform
buffer floor and must become a storage buffer.

common.glsl: add an #include guard.

Converted depth_pass, draw_2d, draw_3d and post to #version 450, #include,
explicit layout(location) on all stage in/out, and per-draw locals UBOs (model,
projection2D, post params) at binding 1. Versioned .spv + .metal blobs committed.

Build: 'make shaders' in shaders/Makefile.am transpiles each entry point
(glslc -D<stage> -> shadercross --msl-version 2.1.0); configure.ac gains
AC_CHECK_PROG for glslc and shadercross. Compiled blobs are versioned, so normal
builds never invoke the tools.

Deferred to Phase 4/5 (C-coupled): depth_resolve (MSAA sampler2DMS resolve),
shadow (needs the lights storage buffer), and the material/lighting shaders
(bsp, mesh, sky, sprite, decal). Refs #864.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mechanical, behavior-preserving rename ahead of the RenderDevice bring-up:
r_context.c/.h -> r_device.c/.h, R_InitContext/R_UpdateContext/R_ShutdownContext
-> R_InitDevice/R_UpdateDevice/R_ShutdownDevice, and the r_context_t type and
global -> r_device_t / r_device, swept consistently across the renderer, client
and ui (and the cgame ABI struct field type). Still OpenGL underneath; the
device object itself lands next.

Verified: the renderer compiles cleanly (r_device.lo and all renderer units
build). The only build failure is ui/QuetooRenderer.c, which is expected and
unrelated -- Phase 0 bumped ObjectivelyMVC to 2.0.0 whose concrete Renderer no
longer matches the old GL bridge; QuetooRenderer is deleted in Phase 3. Refs #864.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t light)

The atomic OpenGL->ObjectivelyGPU landing: the tree builds and runs on SDL_gpu
(Metal/Vulkan/D3D12) with the UI rendering through ObjectivelyGPU. 3D and the 2D
console are stubbed pending Phase 4/5.

r_device.c: R_InitDevice creates the RenderDevice ($(alloc(RenderDevice),
initWithWindow, window)) with a plain (non-SDL_WINDOW_OPENGL) window -- no GL
attributes, context, GLAD or swap interval -- and a present-target Framebuffer
(swapchain format, color only) set via setFramebuffer. R_ShutdownDevice releases
it. r_types.h: r_device_t.context (SDL_GLContext) -> device (RenderDevice *),
and #include <ObjectivelyGPU.h>.

r_main.c: R_BeginFrame acquires the frame via RenderDevice::beginFrame and clears
the framebuffer with a LOADOP_CLEAR render pass; R_EndFrame presents via endFrame.
R_Init/R_Shutdown bypass every GL subsystem (uniforms, images, models, draw2d/3d,
shadows, sky, post, ...) -- ported back in Phase 4/5. R_DrawViewDepth/MainView/
PlayerModelView are stubbed (early return).

ui: Ui_Init uses WindowController::initWithDevice(r_device.device); Ui_Draw calls
render(commands, framebuffer) with the frame's command buffer and framebuffer, so
the menu composites over the cleared frame (LOADOP_LOAD). QuetooRenderer.{c,h}
deleted -- the old GL Renderer bridge is gone.

Builds clean (quetoo, quetoo-dedicated, quetoo-master). This is the point of no
return: OpenGL is no longer initialized. Refs #864.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cgame creates a scene framebuffer via R_CreateFramebuffer on window/pixel-size
events; with GLAD gone that null-derefs glGenFramebuffers. Early-return all of
r_framebuffer.c's entry points (GL bodies preserved for the Phase 5 port to the
ObjectivelyGPU Framebuffer). The 3D scene framebuffer is unused while 3D is
stubbed, so returning a dimensioned, GL-less framebuffer is sufficient.

With this the main menu renders through ObjectivelyMVC on Metal. Refs #864.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
r_image_t gains a `Texture *texture` (additive; GL fields kept vestigial so the
still-stubbed draw paths compile). R_LoadImage's 2D path creates the texture via
$(r_device.device, createTextureFromSurface, surface, SAMPLER) — RGBA32 convert +
upload + row-pitch handled by ObjectivelyGPU — and registers the media itself.
R_SetupImage / R_UploadImageTarget are retired (early return; GL bodies preserved
for reference); R_FreeImage releases the Texture. Cubemap/3D/buffer image types
are deferred (loaded with maps → Phase 5).

Re-enable R_InitMedia / R_ShutdownMedia: the media cache is a pure Objectively
HashTable (no GL) and was wrongly bypassed — that was the R_FindMedia NULL crash.
Stub R_GetError_ (glGetError is meaningless without a GL context), no-oping every
R_GetError call site.

Builds clean. Refs #864.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
R_LoadModel loads MD3/OBJ vertex data into RAM but R_LoadMeshVertexArray then
uploads it to the shared GL mesh VAO/VBO (glGenBuffers/glBufferData/…), which
null-derefs without GLAD. Early-return it (GL body preserved); the mesh draw is
stubbed, so models load without rendering. Ported to a Buffer in Phase 5. Refs #864.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 2D draw subsystem (console, HUD, and the PlayerModelView framebuffer blit via
R_Draw2DFramebuffer) is GL and init-bypassed, so any call null-derefs. Early-return
all 13 public entry points (R_Draw2D*, R_BindFont, R_SetClippingFrame,
R_SetDraw2DProjection, R_StringWidth) — GL bodies preserved for the Phase 5 port to
an ObjectivelyGPU pipeline + dynamic Buffer + font Textures. Unblocks the Player
Setup tab (PlayerModelView). Refs #864.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Vertical slice proving geometry upload + uniforms + pipeline end-to-end,
per issue #864. Renders all world BSP elements with the already-converted
depth_pass shaders (position + MVP -> flat color) into the present
framebuffer, before the full material/lighting BSP program is ported.

- r_device.c: register an Objectively ResourceProvider bridging the GPU
  library's Resource lookups (loadShader) to Quetoo's Fs_Load, so shader
  blobs resolve via the game's search paths. Give the present framebuffer
  a D32_FLOAT depth attachment for 3D depth testing.
- r_bsp_model.c / r_types.h: port world vertex/index buffers to
  ObjectivelyGPU Buffer (createBufferWithConstMem). Neutralize the
  remaining GL in the BSP load path (decal VAOs, voxel light-indices
  buffer, depth-pass VAO) so a map loads without a GL context.
- r_occlude.c: retire glGenQueries during bring-up (Phase 7 Hi-Z).
- r_bsp_draw.c: R_InitWorldPipeline/R_DrawWorld/R_ShutdownWorldPipeline —
  build a GraphicsPipeline from the depth_pass shaders (position-only
  vertex input, 3D preset, cull disabled for bring-up) and draw the world
  indexed, pushing globals + identity model as vertex uniforms.
- r_main.c: un-stub R_DrawMainView (frustum + uniforms + R_DrawWorld);
  drop the dead GL uniform-buffer tail from R_UpdateUniforms.
- shaders/Makefile.am: install the compiled .spv/.metal blobs alongside
  the GLSL so the ResourceProvider can find them.

Not yet runtime-tested (needs `sudo make install` to deploy the shader
blobs). Entities, sprites, shadows, materials/lighting, the dedicated
scene framebuffer, and post are later Phase 5 steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The in-game console draws for a few frames while cls.state == CONNECTING
(key_dest == KEY_CONSOLE), before map load. Cl_DrawConsole derives its
layout from R_BindFont's char width/height — cl_console.height =
(height / ch) - 1 — but the Phase 4 r_draw_2d stub returned ch = 0, so
height became -1 and Cl_DrawConsole_Buffer's VLA `char *lines[height]`
blew the stack (segfault). This path was never hit at the menu; loading
a map is the first time it runs since the 2D stubbing.

R_BindFont now reports non-zero placeholder metrics (8x16) while stubbed,
keeping console/HUD layout math sane. Nothing is drawn until the 2D path
is ported (R_Draw2DString remains stubbed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Loading a map ran R_LoadBspOcclusionQueries -> R_AllocOcclusionQuery,
which dereferences r_occlusion_queries.free/.allocated — Lists that are
never created because R_InitOcclusionQueries is bypassed during the
SDL_gpu port. Segfault at map load. Return NULL for now (the whole
occlusion-query subsystem is ported in Phase 7, compute Hi-Z); callers
store it as block/light->query and R_FreeOcclusionQuery null-checks. The
world draw path bypasses the block/query culling entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two more dormant GL sites first exercised by loading a map with no GL
context:

- R_LoadSky: dropped the glActiveTexture calls (crash at map load). The
  cubemap image still loads its SDL surfaces; the GPU texture upload is
  a no-op until the cubemap path is ported. Sky isn't drawn yet.
- R_DrawPost: early-return. It runs right after R_DrawMainView on the
  first active frame and is entirely GL (bloom/tonemap FBOs). Ported with
  the scene framebuffer + post pipeline later in Phase 5.

Verified the model/sprite load path is already safe (R_LoadMeshVertexArray
was stubbed in Phase 4; 2D image uploads go through ObjectivelyGPU).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
R_CompileAtlas errored "Atlas exceeds GL_MAX_TEXTURE_SIZE" because
r_config.max_texture_size was 0 — R_InitConfig was bypassed during the
port (it queried GL). Reinstated a minimal, GL-free R_InitConfig that
reports the SDL_gpu driver and sets conservative device limits
(max_texture_size 16384, etc.) that hold across Metal/Vulkan/D3D12.
Re-enabled the R_InitConfig() call in R_Init.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
R_CompileAtlas composited the atlas into an SDL_Surface then uploaded it
with GL (glTexParameteri/glTexSubImage2D/glGenerateMipmap) — NULL
function-pointer crash with no GL context, hit while loading a map's
material/sprite atlases (Cl_LoadImages). Create the atlas texture from
the composited surface via createTextureFromSurface, like R_LoadImage's
2D path. Uploads level 0 only; atlas mip generation is deferred until the
material path samples it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The cgame allocates its own scene framebuffer (cg_framebuffer) via
cgi.CreateFramebuffer and asserts cg_framebuffer.name in Cg_PrepareView,
crashing on the first frame — the bring-up stub left name = 0. Return
name = 1 (dimensions were already set) so the assert and the
if (name) destroy-guard work. The GL FBO name is unused: the 3D scene
renders directly into the device present framebuffer during bring-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
R_OccludeBox dereferenced block->query->result, but R_AllocOcclusionQuery
now returns NULL (OQ subsystem retired until Phase 7) and r_occlude
defaults to 1, so cgame entity Think handlers calling R_CulludeBox
segfaulted during Cg_PopulateScene. Early-return false (nothing occluded)
until the query subsystem is ported; also covers R_OccludeSphere, which
delegates here. Other query derefs are either null-guarded (r_decal,
r_light) or on the bypassed BSP block-draw path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Material textures pack diffuse/normal/specular/tint into a 4-layer 2D
array (assembled into a contiguous, layer-major buffer) that was uploaded
via the now-stubbed R_UploadImage, leaving material->texture->texture
NULL. Create it as a Texture (2D_ARRAY, 4 layers) — and the non-material
default case as a plain 2D texture — via createTexture, which uploads all
layers in one copy pass. Level 0 only; material mip generation deferred.
Groundwork for sampling materials in the BSP program (no render change
yet; the bring-up world pipeline still uses depth_pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jdolan and others added 30 commits July 7, 2026 00:20
The lights_cached stat was evaluated once after the loop, reading one
past the last view light; count it per light instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
r_light_t::source carries the cgame's opaque entity token (matching
r_entity_t::id), not a renderer entity pointer -- R_AddEntity copies
entities into the view, so comparing against the copy's address never
matched, and e.g. the quad damage light was shadowed by its own carrier.
This is how main's R_IsLightSource compared, too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bsp_vs runs the shared vertex_lighting() again (clustered voxel + dynamic
diffuse, unshadowed): the voxel light data now being a storage buffer, the
vertex stage can bind the same lighting buffers the fragment stage does,
which the isampler3D scheme precluded. bsp_fragment_lighting blends full
per-fragment lighting out to the vertex lighting over [lighting_distance,
lighting_distance + LIGHTING_LOD_BLEND_DIST] instead of popping at a hard
cut. light.glsl's fragment-only sections (shadow atlas, PCF, fragment_*)
are stage-guarded so vertex shaders can include it without inheriting
shadow bindings, and voxel.glsl's clustered buffers are opted into via
their binding defines. The BSP vertex locals carry the active_lights
bitmask alongside the model matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Buffer::upload acquires and submits its own command buffer -- an extra
queue flush every frame for a 100KB-class copy. A CopyPass on the frame's
command buffer sequences the copy with the frame instead, and streams
through the device's shared scratch transfer buffer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consecutive draw elements within (and across) blocks frequently share a
material and surface flags; re-binding the pipeline and diffusemap
sampler and re-pushing the material uniforms for every draw reached the
SDL driver each time, since SDL_gpu performs no deduplication. Track the
current material per pass, invalidating on material stage draws.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Queries are only ever allocated in bulk during a load and invalidated in
bulk by the next one, so reset the pool in R_BeginLoading and build the
box vertex buffer once in R_EndLoading (R_LoadOcclusionQueries), rather
than checking a dirty flag every frame. This also stops the old BSP's
free -- which happens in R_EndLoading's stale-media sweep, after the new
BSP has allocated its queries -- from resetting the pool out from under
the new map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…loading

Collapse the entity/material/mesh selection traces into one shared
candidate list (cg_editor.c/.h) so the mouse wheel cycles through
overlapping hits on every tab -- fixes decorative surfaces (e.g. a
common/dust brush) blocking selection of what's behind them. The
cursor now advances once in Cg_HandleEvent and tabs just refresh off
it, instead of each tab owning its own trace/candidate state.

Rebuild the Mesh tab on KeyValueTableView (matching Entity/Material)
and keep it permanently mounted but disabled until a model-bearing
entity is selected, replacing the previous add/remove-tab approach
that made the tab bar's fill-equally layout ratchet the panel wider
with each tab.

Add progressive/chunked StageView construction (one per rendered
frame via a private pump view) so switching to a heavily-staged
material no longer stalls for seconds; Add/Remove Stage now patch
the list in place instead of tearing down and rebuilding it.
…u-branch-dry-run

# Conflicts:
#	src/client/renderer/r_sky.c
#	src/collision/cm_material.c
Scrollbar, ScrollThumb, KeyValueView, and KeyValueTableView moved
upstream into ObjectivelyMVC as generic, reusable classes. Delete the
project-local copies and point every consumer at the library headers.
MaterialViewController's stage list now binds Scrollbar directly to its
StackView's own opt-in scrolling instead of wrapping it in a ScrollView.

Also updates the Blend effect's factor dropdown for the jdolan-sdl-gpu-renderer
merge: cm_blend_t is renderer-agnostic and needs no GL header, so the
dropdown references the real enum instead of duplicating GL values.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants