#include "editor.h" #include "raygui.h" #include "save.h" #include "cam.h" #include "physics.h" #include "ent.h" #include "app.h" #include "res.h" #include "env.h" static bool started = false; static Vec2 start_point = {0}; static Vec2 end_point = {0}; static Rect sel_rec = {0}; static f32 sel_dist = 0; static EditorAction tr_action = {0}; typedef struct { Vec3 pos; Quaternion rot; } RotationRecord; static RotationRecord *rot_record; static u32 rot_record_len, rot_record_cap; static const f32 rotation_steps[] = {1, 5, 15, 30, 45, 90}; EditorInfo ed = { .panel = { [ED_PNL_LEFT] = { .type = ED_PNL_TYPE_SCENE, // .enabled = true, }, [ED_PNL_RIGHT] = { .type = ED_PNL_TYPE_PROPERTIES, }, [ED_PNL_CENTER] = { .type = ED_PNL_TYPE_SPACE, }, }, }; typedef enum { ASSET_PICK_MDL_COMP_ADD, ASSET_PICK_MDL_COMP_UPDATE, ASSET_PICK_MDL_ENT_CREATE, ASSET_PICK_MAT_TEX, } AssetPickerMode; typedef struct { cstr key; int score; Asset *asset; bool tried_loading; } AssetChoice; static struct { RenderTexture preview; AssetChoice *items; int item_cnt, match_cnt, sel_idx, scroll; AssetTag tag; AssetPickerMode mode; u32 mat_idx; // material index dummy char query[128]; } asset_picker; typedef enum { ED_CMD_NONE = -1, ED_CMD_TIME, ED_CMD_SPAWN_PREFAB, ED_CMD_SPAWN_MODEL, ED_CMD_CNT, } EditorCommand; static struct { char query[128]; int selected; } command_menu; static const cstr material_shader_keys[] = { "default", "alpha_cutoff", "alpha_cutoff_nocull", "foliage", }; static const char material_shader_labels[] = "default;alpha_cutoff;alpha_cutoff_nocull;foliage"; /* forward decl */ static bool selection_root(Entity *ent); static void action_apply(EditorAction*, bool); static void action_free(EditorAction*); static void asset_picker_open(AssetTag tag, AssetPickerMode mode); static void asset_picker_close(void); static void physics_pick(void); static void editor_set_material(cstr key, u32 mat_idx, MaterialProperty prop); static void history_push(EditorAction action) { if (ed.history_save_csr > ed.history_csr) { ed.history_save_csr = UINT32_MAX; } /* discard redo branch */ while (ed.history_cnt > ed.history_csr) { action_free(&ed.history[--ed.history_cnt]); } if (ed.history_cnt == ed.history_cap) { u32 cap = ed.history_cap ? ed.history_cap * 2 : 64; void *history = realloc(ed.history, cap * sizeof(action)); if (!history) { perror("editor history alloc failed."); exit(1); } ed.history = history; ed.history_cap = cap; } ed.history[ed.history_cnt++] = action; ed.history_csr = ed.history_cnt; } static void history_undo(void) { if (ed.history_csr > 0) { action_apply(&ed.history[ed.history_csr - 1], true); ed.history_csr -= 1; } } static void history_redo(void) { if (ed.history_csr < ed.history_cnt) { action_apply(&ed.history[ed.history_csr], false); ed.history_csr += 1; } } static void history_clear(void) { for (u32 i = 0; i < ed.history_cnt; ++i) { action_free(&ed.history[i]); } ed.history_cnt = 0; ed.history_csr = 0; ed.history_save_csr = UINT32_MAX; } static void action_entity_extract(EditorAction *action) { for (u32 i = 0; i < action->item_cnt; ++i) { Entity *held = &action->item[i].before.stored.ent; assert(!held->pid); u64 id = entity_id_from_pid(action->item[i].pid); *held = entity_extract_now(id); } } static void action_entity_restore(EditorAction *action) { /* Restore all entities before resolving their relationships. */ for (u32 i = 0; i < action->item_cnt; ++i) { Entity *held = &action->item[i].before.stored.ent; assert(held->pid == action->item[i].pid); u64 id = entity_restore(action->item[i].pid); assert(id); *entity_get(id) = *held; *held = (Entity){0}; } for (u32 i = 0; i < action->item_cnt; ++i) { const EditorActionData *data = &action->item[i].before; u64 id = entity_id_from_pid(action->item[i].pid); Entity *ent = entity_get(id); ent->tr.parent = entity_id_from_pid(data->stored.parent); ent->tr.prev = entity_id_from_pid(data->stored.prev); ent->tr.next = entity_id_from_pid(data->stored.next); if (ent->tr.prev) { entity_get(ent->tr.prev)->tr.next = id; } else if (ent->tr.parent) { entity_get(ent->tr.parent)->tr.first_child = id; } if (ent->tr.next) { entity_get(ent->tr.next)->tr.prev = id; } } } /* collects PIDs, recursive visits children first. */ static void action_collect(EditorAction *action, u64 id, bool recursive) { Entity *ent = entity_get(id); assert(ent && ent->pid); if (recursive) { for (u64 child = ent->tr.first_child; child; child = entity_get(child)->tr.next) { action_collect(action, child, true); } } u32 i = action->item_cnt++; /* First pass counts, second pass fills the allocated array. */ if (action->item) { action->item[i].pid = ent->pid; } } static EditorAction action_begin(EditorActionTag tag, bool roots_only) { EditorAction action = { .tag = tag }; // bool roots_only = false; bool recursive = false; switch (tag) { case ED_ACTION_TRANSFORM: // roots_only = true; break; case ED_ACTION_CREATE: action.item_cnt = 1; action.item = calloc(1, sizeof(*action.item)); if (!action.item) { perror("action_begin: create entity alloc failed."); exit(1); } u64 id = entity_create(false); Entity *ent = entity_get(id); action.item[0].pid = ent->pid; entities_clear_selected(); ent->ed.selected = true; return action; case ED_ACTION_DELETE: case ED_ACTION_DUPLICATE: recursive = true; break; case ED_ACTION_RENAME: break; case ED_ACTION_MODEL: break; case ED_ACTION_PHYSICS: break; case ED_ACTION_TIME: action.item_cnt = 1; action.item = calloc(1, sizeof(*action.item)); if (!action.item) { perror("action_begin: time action alloc failed."); exit(1); } /* Scene settings: this item's pid is unused. */ action.item[0].before.time.day_cur = env.day_cur; action.item[0].before.time.day_len = env.day_len; return action; default: TraceLog(LOG_FATAL, "action_begin: unsupported tag."); } /* Count, allocate, then collect PIDs. */ for (u32 pass = 0; pass < 2; ++pass) { for (u32 i = 0; i < entity_count(); ++i) { Entity *ent = &entity_data()[i]; if (!ent || !ent->pid) { continue; } if (roots_only ? !selection_root(ent) : !ent->ed.selected) { continue; } action_collect(&action, entity_id_at(i), recursive); } if (pass == 0) { if (!action.item_cnt) { return action; } action.item = calloc(action.item_cnt, sizeof(*action.item)); if (!action.item) { perror("editor action alloc failed."); exit(1); } action.item_cnt = 0; } } /* Capture action-specific data for the collected entities. */ for (u32 i = 0; i < action.item_cnt; ++i) { Entity *ent = entity_get( entity_id_from_pid(action.item[i].pid)); assert(ent); EditorActionData *before = &action.item[i].before; switch (tag) { case ED_ACTION_TRANSFORM: before->transform.pos = ent->tr.pos; before->transform.rot = ent->tr.rot; before->transform.scale = ent->tr.scale; break; case ED_ACTION_DELETE: { Entity *parent = entity_get(ent->tr.parent); Entity *prev = entity_get(ent->tr.prev); Entity *next = entity_get(ent->tr.next); assert(!parent || parent->pid); assert(!prev || prev->pid); assert(!next || next->pid); before->stored.parent = parent ? parent->pid : 0; before->stored.prev = prev ? prev->pid : 0; before->stored.next = next ? next->pid : 0; break; } case ED_ACTION_DUPLICATE: break; case ED_ACTION_RENAME: // Name ownership will transfer when the rename is // submitted. break; case ED_ACTION_MODEL: before->model = entity_model_copy(&ent->mdl); break; case ED_ACTION_PHYSICS: before->physics = entity_physics_copy(&ent->phys); break; default: TraceLog(LOG_FATAL, "action_begin: unhandled action tag."); break; } } return action; } static void action_apply(EditorAction *action, bool undo) { switch (action->tag) { case ED_ACTION_TRANSFORM: for (u32 i = 0; i < action->item_cnt; ++i) { u64 id = entity_id_from_pid(action->item[i].pid); Entity *ent = entity_get(id); assert(ent); const EditorActionData *data = undo ? &action->item[i].before : &action->item[i].after; ent->tr.pos = data->transform.pos; ent->tr.rot = data->transform.rot; ent->tr.scale = data->transform.scale; } /* second pass ensures transforms are properly updated */ for (u32 i = 0; i < action->item_cnt; ++i) { u64 id = entity_id_from_pid(action->item[i].pid); entity_physics_transform_changed(id); } break; case ED_ACTION_REPARENT: { /* Detach everything before restoring any recorded links. */ for (u32 i = 0; i < action->item_cnt; ++i) { u64 id = entity_id_from_pid(action->item[i].pid); entity_set_parent(id, 0); } for (u32 i = 0; i < action->item_cnt; ++i) { u64 id = entity_id_from_pid(action->item[i].pid); Entity *ent = entity_get(id); const EditorActionData *data = undo ? &action->item[i].before : &action->item[i].after; ent->tr.parent = entity_id_from_pid(data->reparent.parent); ent->tr.prev = entity_id_from_pid(data->reparent.prev); ent->tr.next = entity_id_from_pid(data->reparent.next); ent->tr.pos = data->reparent.pos; ent->tr.rot = data->reparent.rot; ent->tr.scale = data->reparent.scale; } /* All moved entities now have their final relationships. */ for (u32 i = 0; i < action->item_cnt; ++i) { u64 id = entity_id_from_pid(action->item[i].pid); Entity *ent = entity_get(id); if (ent->tr.prev) { entity_get(ent->tr.prev)->tr.next = id; } else if (ent->tr.parent) { entity_get(ent->tr.parent)->tr.first_child = id; } if (ent->tr.next) { entity_get(ent->tr.next)->tr.prev = id; } entity_physics_transform_changed(id); } break; } case ED_ACTION_CREATE: case ED_ACTION_DUPLICATE: if (undo) { action_entity_extract(action); } else { action_entity_restore(action); } break; case ED_ACTION_DELETE: if (undo) { action_entity_restore(action); } else { action_entity_extract(action); } break; case ED_ACTION_RENAME: for (u32 i = 0; i < action->item_cnt; ++i) { u64 id = entity_id_from_pid(action->item[i].pid); Entity *ent = entity_get(id); assert(ent); char *name = ent->name; ent->name = action->item[i].before.rename.name; action->item[i].before.rename.name = name; } break; case ED_ACTION_MODEL: for (u32 i = 0; i < action->item_cnt; ++i) { u64 id = entity_id_from_pid(action->item[i].pid); Entity *ent = entity_get(id); ModelCmp model = ent->mdl; ent->mdl = action->item[i].before.model; action->item[i].before.model = model; } break; case ED_ACTION_PHYSICS: for (u32 i = 0; i < action->item_cnt; ++i) { u64 id = entity_id_from_pid(action->item[i].pid); Entity *ent = entity_get(id); entity_physics_clear(&ent->phys); PhysicsCmp pre = ent->phys; ent->phys = action->item[i].before.physics; action->item[i].before.physics = pre; } break; case ED_ACTION_TIME: { const EditorActionData *data = undo ? &action->item[0].before : &action->item[0].after; env.day_cur = data->time.day_cur; env.day_len = data->time.day_len; break; } default: TraceLog(LOG_FATAL, "action with invalid tag."); break; } } static void action_free(EditorAction *action) { switch (action->tag) { case ED_ACTION_TRANSFORM: break; case ED_ACTION_REPARENT: break; case ED_ACTION_CREATE: case ED_ACTION_DELETE: case ED_ACTION_DUPLICATE: for (u32 i = 0; i < action->item_cnt; ++i) { entity_clean(&action->item[i].before.stored.ent); } break; case ED_ACTION_RENAME: for (u32 i = 0; i < action->item_cnt; ++i) { free(action->item[i].before.rename.name); } break; case ED_ACTION_MODEL: for (u32 i = 0; i < action->item_cnt; ++i) { entity_model_free(&action->item[i].before.model); } break; case ED_ACTION_PHYSICS: for (u32 i = 0; i < action->item_cnt; ++i) { entity_physics_free(&action->item[i].before.physics); } break; case ED_ACTION_TIME: break; default: TraceLog(LOG_FATAL, "action_free: unhandled action tag."); break; } free(action->item); } static void action_commit(EditorAction *action) { bool changed = false; switch (action->tag) { case ED_ACTION_TRANSFORM: for (u32 i = 0; i < action->item_cnt; ++i) { u64 id = entity_id_from_pid(action->item[i].pid); Entity *ent = entity_get(id); assert(ent); const EditorActionData *before = &action->item[i].before; EditorActionData *after = &action->item[i].after; after->transform.pos = ent->tr.pos; after->transform.rot = ent->tr.rot; after->transform.scale = ent->tr.scale; changed |= !Vector3Equals(before->transform.pos, after->transform.pos) || !QuaternionEquals(before->transform.rot, after->transform.rot) || !Vector3Equals(before->transform.scale, after->transform.scale); entity_physics_transform_changed(id); } if (!changed) { action_apply(action, true); } break; case ED_ACTION_CREATE: case ED_ACTION_DELETE: case ED_ACTION_DUPLICATE: changed = action->item_cnt != 0; break; case ED_ACTION_RENAME: for (u32 i = 0; i < action->item_cnt; ++i) { u64 id = entity_id_from_pid(action->item[i].pid); Entity *ent = entity_get(id); assert(ent); cstr before = action->item[i].before.rename.name; changed |= strcmp(before ? before : "", ent->name ? ent->name : "") != 0; } if (!changed) { action_apply(action, true); } break; case ED_ACTION_MODEL: for (u32 i = 0; i < action->item_cnt; ++i) { u64 id = entity_id_from_pid(action->item[i].pid); Entity *ent = entity_get(id); assert(ent); ModelCmp *before = &action->item[i].before.model; ModelCmp *after = &ent->mdl; changed |= before->has != after->has || before->draw != after->draw || strcmp(before->key ? before->key : "", after->key ? after->key : "") != 0; if (before->mat_cnt != after->mat_cnt) { changed = true; continue; } for (u32 j = 0; j < before->mat_cnt; ++j) { const MaterialOverride *a, *b; a = &before->materials[j]; b = &after->materials[j]; changed |= strcmp(a->tex_key ? a->tex_key : "", b->tex_key ? b->tex_key : "") != 0; changed |= strcmp(a->shader_key ? a->shader_key : "", b->shader_key ? b->shader_key : "") != 0; changed |= a->uv_scale.x != b->uv_scale.x || a->uv_scale.y != b->uv_scale.y || a->uv_offset.x != b->uv_offset.x || a->uv_offset.y != b->uv_offset.y; } } break; case ED_ACTION_PHYSICS: changed = action->item_cnt != 0; break; case ED_ACTION_TIME: { const EditorActionData *before = &action->item[0].before; EditorActionData *after = &action->item[0].after; after->time.day_cur = env.day_cur; after->time.day_len = env.day_len; changed = before->time.day_cur != after->time.day_cur || before->time.day_len != after->time.day_len; break; } default: TraceLog(LOG_FATAL, "action_commit: unhangled action tag."); break; } if (changed) { history_push(*action); } else { action_free(action); } *action = (EditorAction){0}; } void editor_init(void) { // GuiLoadStyle("style_amber.rgs"); // GuiLoadStyle("style_candy.rgs"); // GuiLoadStyle("style_rltech.rgs"); Asset *fnt_asset = asset_get("res/fnt/Aileron-Regular.otf"); if (fnt_asset) { GuiSetFont(fnt_asset->fnt); GuiSetStyle(DEFAULT, TEXT_SIZE, 16); } /* probably will delete this later */ ed.history_save_csr = save_file_read("res/save/scene.fgs") ? ed.history_csr : UINT32_MAX; ed.time_preview = env.day_cur; ed.grid_sz = 1.0f; ed.rotation_snap = 2; // 15 degrees rot_record_cap = 16; rot_record_len = 0; rot_record = malloc(rot_record_cap * sizeof(*rot_record)); if (!rot_record) { perror("rot_record_start allocation failed"); exit(1); } } void editor_exit(void) { asset_picker_close(); free(rot_record); action_free(&tr_action); tr_action = (EditorAction){0}; history_clear(); free(ed.history); ed.history = NULL; ed.history_cap = 0; } static void editor_focus_selection(void) { Vec3 minimum = {FLT_MAX, FLT_MAX, FLT_MAX}; Vec3 maximum = {-FLT_MAX, -FLT_MAX, -FLT_MAX}; bool found = false; for (u32 i = 0; i < entity_count(); ++i) { u64 id = entity_id_at(i); Entity *ent = entity_get(id); if (!ent->ed.selected) { continue; } found = true; Matrix world = entity_world_matrix(id); BoundingBox bounds = {0}; if (ent->mdl.has && ent->mdl.draw && ent->mdl.mdl) { Model model = *ent->mdl.mdl; model.transform = MatrixIdentity(); bounds = GetModelBoundingBox(model); } for (int corner = 0; corner < 8; ++corner) { Vec3 point = { (corner & 1) ? bounds.max.x : bounds.min.x, (corner & 2) ? bounds.max.y : bounds.min.y, (corner & 4) ? bounds.max.z : bounds.min.z, }; point = Vector3Transform(point, world); minimum = Vector3Min(minimum, point); maximum = Vector3Max(maximum, point); } } if (!found) { return; } Vec3 center = Vector3Scale( Vector3Add(minimum, maximum), 0.5f); f32 radius = fmaxf( Vector3Distance(minimum, maximum) * 0.5f, 0.5f); /* Leave space for panels on either side of the view. */ f32 width = GetScreenWidth(); f32 height = GetScreenHeight(); f32 margin = 0; if (ed.panel[ED_PNL_LEFT].enabled) { Rect bounds = ed.panel[ED_PNL_LEFT].bounds; margin = fmaxf(margin, bounds.x + bounds.width); } if (ed.panel[ED_PNL_RIGHT].enabled) { Rect bounds = ed.panel[ED_PNL_RIGHT].bounds; margin = fmaxf(margin, width - bounds.x); } f32 visible_width = fmaxf(width - 2.0f * margin, 1.0f); f32 aspect = visible_width / height; f32 half_fov = main_cam.perspective_fov * DEG2RAD * 0.5f; f32 horizontal = atanf(tanf(half_fov) * aspect); f32 angle = fminf(half_fov, horizontal); f32 distance = radius / sinf(angle) * 1.15f; f32 view_height = 0; if (main_cam.cam.projection == CAMERA_ORTHOGRAPHIC) { view_height = 2.0f * radius * 1.15f / fminf(aspect, 1.0f); } cam_focus(center, distance, view_height); } static void editor_duplicate(void) { EditorAction action; action = action_begin(ED_ACTION_DUPLICATE, true); if (!action.item_cnt) { action_free(&action); return; } u64 *copies = calloc(action.item_cnt, sizeof(*copies)); if (!copies) { perror("editor_duplicate: alloc failed."); exit(1); } for (u32 i = 0; i < action.item_cnt; ++i) { copies[i] = entity_create(false); /* Creation may reallocate the entity array. */ u64 source_id = entity_id_from_pid(action.item[i].pid); Entity *source = entity_get(source_id); Entity *copy = entity_get(copies[i]); free(copy->name); copy->name = source->name ? strdup(source->name) : NULL; copy->tr.pos = source->tr.pos; copy->tr.rot = source->tr.rot; copy->tr.scale = source->tr.scale; copy->mdl = entity_model_copy(&source->mdl); copy->phys = entity_physics_copy(&source->phys); copy->ed.selected = source->ed.selected; source->ed.selected = false; } /* * Collection visits children first. Linking in reverse order * preserves sibling order when set_parent prepends children. */ for (u32 n = action.item_cnt; n > 0; --n) { u32 i = n - 1; u64 source_id = entity_id_from_pid(action.item[i].pid); Entity *source = entity_get(source_id); u64 parent_id = source->tr.parent; Entity *parent = entity_get(parent_id); u64 parent_pid = parent ? parent->pid : 0; for (u32 j = 0; j < action.item_cnt; ++j) { if (action.item[j].pid == parent_pid) { parent_id = copies[j]; break; } } entity_set_parent(copies[i], parent_id); } /* Record the completed hierarchy for undo and redo. */ for (u32 i = 0; i < action.item_cnt; ++i) { Entity *copy = entity_get(copies[i]); Entity *parent = entity_get(copy->tr.parent); Entity *prev = entity_get(copy->tr.prev); Entity *next = entity_get(copy->tr.next); EditorActionData *data = &action.item[i].before; action.item[i].pid = copy->pid; data->stored.parent = parent ? parent->pid : 0; data->stored.prev = prev ? prev->pid : 0; data->stored.next = next ? next->pid : 0; } free(copies); action_commit(&action); } void editor_update(void) { bool ctrl = IsKeyDown(KEY_LEFT_CONTROL); bool shift = IsKeyDown(KEY_LEFT_SHIFT); bool alt = IsKeyDown(KEY_LEFT_ALT); if (ed.time_preview_playing) { ed.time_preview = fmodf( ed.time_preview + GetFrameTime() / env.day_len, 1.0f); } env.cycle = ed.time_preview; /* sorry about the indentation :^) */ switch (ed.interact) { case ED_INTERACT_NONE: if (!ed.focus_captured && ed.mode == ED_MODE_SCENE && IsKeyPressed(KEY_SPACE)) { ed.panel[ED_PNL_CENTER].type = ED_PNL_TYPE_SPACE; ed.panel[ED_PNL_CENTER].enabled = true; } if (ed.panel[ED_PNL_CENTER].enabled) { ed.focus = ED_PNL_CENTER; ed.focus_captured = true; break; } if (!ed.focus_captured) { ed.focus = 0; Vec2 mpos = GetMousePosition(); if (ed.panel[ED_PNL_LEFT].enabled && CheckCollisionPointRec(mpos, ed.panel[ED_PNL_LEFT].bounds)) { ed.focus = ED_PNL_LEFT; } if (ed.panel[ED_PNL_RIGHT].enabled && CheckCollisionPointRec(mpos, ed.panel[ED_PNL_RIGHT].bounds)) { ed.focus = ED_PNL_RIGHT; } } if (!ed.focus_captured && ctrl && IsKeyPressed(KEY_Z)) { if (shift) { history_redo(); } else { history_undo(); } } if (ed.focus != 0) { break; } if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) || (main_cam.state == CAM_MODE_EDIT_ORBIT && IsMouseButtonPressed(MOUSE_BUTTON_MIDDLE))) { ed.interact = ED_INTERACT_CAMERA; main_cam.update = false; DisableCursor(); break; } if (ed.mode == ED_MODE_PHYSICS) { if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { physics_pick(); } break; } if (ed.mode != ED_MODE_SCENE) { break; } if (!ctrl && !shift && !alt && IsKeyPressed(KEY_C)) { ed.draw_physics = !ed.draw_physics; } if (!ctrl && IsKeyPressed(KEY_F)) { editor_focus_selection(); break; } if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { ed.interact = ED_INTERACT_SELECT; editor_select(); // capture this frame break; } if (IsKeyPressed(KEY_G)) { if (!ctrl && ed.sel_cnt) { ed.interact = ED_INTERACT_MOVE; break; } } if (IsKeyPressed(KEY_R)) { if (!ctrl && ed.sel_cnt) { ed.interact = ED_INTERACT_ROTATE; break; } if (ctrl) { history_clear(); entity_system_clear(); ed.history_save_csr = save_file_read("res/save/scene.fgs") ? ed.history_csr : UINT32_MAX; ed.time_preview = env.day_cur; } } if (IsKeyPressed(KEY_S)) { if (ctrl) { ed.history_save_csr = save_file_write("res/save/scene.fgs") ? ed.history_csr : UINT32_MAX; } else if (ed.sel_cnt) { ed.interact = ED_INTERACT_SCALE; break; } } if (ctrl && IsKeyPressed(KEY_D) && ed.sel_cnt) { editor_duplicate(); ed.interact = ED_INTERACT_MOVE; break; } if (IsKeyPressed(KEY_DELETE)) { if (!ctrl && ed.sel_cnt) { EditorAction action = action_begin(ED_ACTION_DELETE, true); if (action.item_cnt) { action_apply(&action, false); action_commit(&action); } } } // if (IsKeyPressed(KEY_A)) { // if (ctrl) { // for (u32 i = 0; i < entity_count(); ++i) { // u64 id = entity_id_at(i); // Entity *ent = entity_get(id); // if (ent && ent->pid) { // ent->ed.selected = true; // } // } // } // } break; case ED_INTERACT_SELECT: editor_select(); break; case ED_INTERACT_CAMERA: { bool held = IsMouseButtonDown(MOUSE_BUTTON_RIGHT); if (main_cam.state == CAM_MODE_EDIT_ORBIT) { held |= IsMouseButtonDown(MOUSE_BUTTON_MIDDLE); } if (!held) { ed.interact = ED_INTERACT_NONE; main_cam.update = false; EnableCursor(); break; } main_cam.update = true; break; } case ED_INTERACT_MOVE: editor_move(); break; case ED_INTERACT_ROTATE: editor_rotate(); break; case ED_INTERACT_SCALE: editor_scale(); break; default: TraceLog(LOG_FATAL, "editor_update: unhandled ed.interact value"); break; } main_cam.input = (ed.interact == ED_INTERACT_CAMERA && main_cam.update) || (ed.interact == ED_INTERACT_NONE && ed.focus == ED_PNL_NULL && !ed.focus_captured && !ed.panel[ED_PNL_CENTER].enabled); /* reset selection data */ ed.sel_cnt = 0; ed.sel_point = Vector3Zero(); ed.sel_rot = QuaternionIdentity(); for (u32 i = 0; i < entity_count(); ++i) { u64 id = entity_id_at(i); Entity *ent = entity_get(id); if (!ent->ed.selected) { continue; } ed.sel_cnt++; ed.sel_point = Vector3Add(ed.sel_point, entity_world_pos(id)); ed.sel_rot = entity_world_rot(id); } if (ed.sel_cnt > 0) { ed.sel_point = Vector3Scale( ed.sel_point, 1.0f / ed.sel_cnt); } if (ed.sel_cnt != 1) { ed.sel_rot = QuaternionIdentity(); } main_cam.view_rotation = ed.sel_rot; cam_update(); } /* tools */ static void sel_ray(void) { Ray ray = GetScreenToWorldRay(end_point, main_cam.cam); Vec3 dir = Vector3Scale(ray.direction, 1000.0f); b3QueryFilter filter = { .categoryBits = BIT(PHYS_MASK_EDITOR_RAYCAST), .maskBits = BIT(PHYS_MASK_EDITOR_SELECTABLE), }; b3RayResult res = b3World_CastRayClosest( physics.world_id, (b3Pos){ray.position.x, ray.position.y, ray.position.z}, (b3Vec3){dir.x, dir.y, dir.z}, filter); if (ed.focus == ED_PNL_NULL && !IsKeyDown(KEY_LEFT_SHIFT)) { entities_clear_selected(); } if (!res.hit) { return; } b3BodyId body = b3Shape_GetBody(res.shapeId); u64 *id = b3Body_GetUserData(body); if (!id) { return; } Entity *ent = entity_get(*id); if (!ent || !ent->pid) { return; } ent->ed.selected = !ent->ed.selected; } static void physics_pick(void) { ed.physics_body = -1; ed.physics_shape = -1; Entity *ent = entity_get(entity_id_from_pid(ed.physics_pid)); if (!ent || !ent->phys.runtime.bodies) { return; } Ray ray = GetScreenToWorldRay(GetMousePosition(), main_cam.cam); Vec3 dir = Vector3Scale(ray.direction, 1000.0f); b3Pos origin = { ray.position.x, ray.position.y, ray.position.z, }; b3Vec3 translation = {dir.x, dir.y, dir.z}; f32 closest = 1.0f; PhysicsCmp *phys = &ent->phys; for (u32 i = 0; i < phys->body_cnt; ++i) { PhysicsBody *body = &phys->bodies[i]; PhysicsBodyRuntime *runtime = &phys->runtime.bodies[i]; for (u32 j = 0; j < body->shape_cnt; ++j) { b3ShapeId shape = runtime->shapes[j]; if (!b3Shape_IsValid(shape)) { continue; } b3WorldCastOutput hit = b3Shape_RayCast( shape, origin, translation); if (hit.hit && hit.fraction <= closest) { closest = hit.fraction; ed.physics_body = (int)i; ed.physics_shape = (int)j; } } } } static void sel_area(void) { Vec3 fwd = Vector3Subtract(main_cam.cam.target, main_cam.cam.position); for (u32 i = 0; i < entity_count(); ++i) { u64 id = entity_id_at(i); Entity *ent = entity_get(id); if (ent->pid == 0) { continue; } if (!IsKeyDown(KEY_LEFT_SHIFT)) { ent->ed.selected = false; } Vec3 world_pos = entity_world_pos(id); Vec3 offset = Vector3Subtract(world_pos, main_cam.cam.position); // Only the dot product’s sign matters, so neither vector // needs normalization. if (Vector3DotProduct(fwd, offset) <= 0) { continue; } Vec2 pos = GetWorldToScreen(world_pos, main_cam.cam); if (CheckCollisionPointRec(pos, sel_rec)) { ent->ed.selected = true; } } } static void sel_rec_draw(void) { DrawRectangleRec(sel_rec, (Color) { 255, 255, 255, 40 }); DrawRectangleLines(sel_rec.x, sel_rec.y, sel_rec.width, sel_rec.height, (Color) { 255, 255, 255, 80 }); } static bool selection_root(Entity *ent) { if (!ent->ed.selected) { return false; } for (u64 parent = ent->tr.parent; parent;) { Entity *ancestor = entity_get(parent); assert(ancestor); if (ancestor->ed.selected) { return false; } parent = ancestor->tr.parent; } return true; } static Vec3 selected_pivot(void) { Vec3 pivot = {0}; int count = 0; for (int i = 0; i < entity_count(); ++i) { u64 id = entity_id_at(i); Entity *ent = entity_get(id); if (!ent || !ent->ed.selected) { continue; } pivot = Vector3Add(pivot, entity_world_pos(id)); count++; } if (count > 0) { pivot = Vector3Scale(pivot, 1.0f / (f32)count); } return pivot; } void editor_select(void) { if (!started) { start_point = GetMousePosition(); started = true; } end_point = GetMousePosition(); sel_dist = Vector2Distance(start_point, end_point); sel_rec = (Rect) { fminf(start_point.x, end_point.x), fminf(start_point.y, end_point.y), fabs(end_point.x - start_point.x), fabs(end_point.y - start_point.y) }; if (IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { if (sel_dist < 20) { sel_ray(); } else { sel_area(); } started = false; sel_dist = 0; ed.interact = ED_INTERACT_NONE; } } static bool ray_plane_hit(Vec3 plane_pos, Vec3 plane_dir, Vec3 *out) { Ray ray = GetScreenToWorldRay(GetMousePosition(), main_cam.cam); f32 denom = Vector3DotProduct(ray.direction, plane_dir); if (fabsf(denom) < 0.0001f) { return false; } Vec3 offset = Vector3Subtract(plane_pos, ray.position); f32 dist = Vector3DotProduct(offset, plane_dir) / denom; if (dist < 0.0f) { return false; } *out = Vector3Add(ray.position, Vector3Scale(ray.direction, dist)); return true; } void editor_move(void) { static Vec3 mv_start; static Vec3 previous_axis; static bool previous_plane; static Vec3 raw_pivot; static bool previous_local; if (!tr_action.item) { tr_action = action_begin(ED_ACTION_TRANSFORM, true); mv_start = selected_pivot(); raw_pivot = mv_start; ed.move_snap_origin = mv_start; previous_local = false; started = true; ed.move_rotation = ed.sel_rot; ed.move_axis = -1; ed.move_plane = true; previous_axis = (Vec3){0, 1, 0}; previous_plane = true; } bool cancel = IsKeyPressed(KEY_ESCAPE) || !tr_action.item_cnt; if (cancel || IsKeyPressed(KEY_ENTER) || IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { if (cancel) { action_apply(&tr_action, true); action_free(&tr_action); tr_action = (EditorAction){0}; } else { action_commit(&tr_action); } started = false; ed.interact = ED_INTERACT_NONE; return; } if (IsKeyDown(KEY_LEFT_CONTROL)) { f32 wheel = GetMouseWheelMove(); if (wheel != 0) { ed.grid_sz = Clamp( ed.grid_sz * powf(2.0f, wheel), 0.125f, 64.0f); } } bool reset = false; if (IsKeyPressed(KEY_L)) { main_cam.local_space = !main_cam.local_space; reset = true; } bool local = main_cam.local_space && ed.sel_cnt == 1; int keys[3] = {KEY_X, KEY_Y, KEY_Z}; for (int i = 0; i < 3; ++i) { if (!IsKeyPressed(keys[i])) { continue; } reset = true; bool plane = IsKeyDown(KEY_LEFT_SHIFT); if (ed.move_axis == i && ed.move_plane == plane) { ed.move_axis = -1; ed.move_plane = true; } else { ed.move_axis = i; ed.move_plane = plane; } } /* Global XZ is simply the default floor plane. */ if (!local && ed.move_axis == 1 && ed.move_plane) { ed.move_axis = -1; } Vec3 axes[3] = { {1, 0, 0}, {0, 1, 0}, {0, 0, 1}, }; Vec3 axis = {0, 1, 0}; bool plane = true; if (ed.move_axis >= 0) { axis = axes[ed.move_axis]; plane = ed.move_plane; if (local) { axis = Vector3RotateByQuaternion(axis, ed.move_rotation); } } if (IsKeyDown(KEY_LEFT_ALT)) { axis = (Vec3){0, 1, 0}; plane = false; } bool use_local = local && ed.move_axis >= 0 && !IsKeyDown(KEY_LEFT_ALT); if (reset || !Vector3Equals(axis, previous_axis) || plane != previous_plane || use_local != previous_local) { if (reset) { action_apply(&tr_action, true); } previous_axis = axis; previous_plane = plane; previous_local = use_local; raw_pivot = selected_pivot(); ed.move_snap_origin = raw_pivot; started = false; } Vec3 pivot = selected_pivot(); Vec3 delta = {0}; if (plane) { Vec3 hit; if (!ray_plane_hit(pivot, axis, &hit)) { return; } if (!started) { mv_start = hit; started = true; return; } delta = Vector3Subtract(hit, mv_start); mv_start = hit; } else { if (!started) { started = true; return; } Vec2 a = GetWorldToScreen(pivot, main_cam.cam); Vec2 b = GetWorldToScreen( Vector3Add(pivot, axis), main_cam.cam); Vec2 screen_axis = Vector2Subtract(b, a); f32 length_squared = Vector2LengthSqr(screen_axis); if (length_squared <= 0.001f) { return; } f32 amount = Vector2DotProduct( GetMouseDelta(), screen_axis) / length_squared; delta = Vector3Scale(axis, amount); } raw_pivot = Vector3Add(raw_pivot, delta); Vec3 destination = raw_pivot; if (IsKeyDown(KEY_LEFT_CONTROL) && ed.grid_sz > 0) { int constrained_axis = ed.move_axis >= 0 ? ed.move_axis : 1; if (IsKeyDown(KEY_LEFT_ALT)) { constrained_axis = 1; } Vec3 origin = use_local ? ed.move_snap_origin : Vector3Zero(); for (int i = 0; i < 3; ++i) { bool enabled = plane ? i != constrained_axis : i == constrained_axis; if (!enabled) { continue; } Vec3 direction = axes[i]; if (use_local) { direction = Vector3RotateByQuaternion( direction, ed.move_rotation); } f32 coordinate = Vector3DotProduct( Vector3Subtract(raw_pivot, origin), direction); f32 snapped = roundf(coordinate / ed.grid_sz) * ed.grid_sz; destination = Vector3Add(destination, Vector3Scale(direction, snapped - coordinate)); } } delta = Vector3Subtract(destination, pivot); for (u32 i = 0; i < tr_action.item_cnt; ++i) { u64 id = entity_id_from_pid(tr_action.item[i].pid); Entity *ent = entity_get(id); assert(ent); Vec3 pos = Vector3Add(entity_world_pos(id), delta); entity_set_world_pos(ent, pos); entity_physics_transform_changed(id); } } void editor_rotate(void) { static f32 mouse_angle; if (!tr_action.item) { tr_action = action_begin(ED_ACTION_TRANSFORM, true); started = false; ed.rotate_axis = -1; mouse_angle = 0; rot_record_len = tr_action.item_cnt; ed.rotate_pivot = selected_pivot(); if (rot_record_len > rot_record_cap) { u32 capacity = rot_record_cap; while (capacity < rot_record_len) { capacity *= 2; } RotationRecord *resized = realloc(rot_record, capacity * sizeof(*rot_record)); if (!resized) { perror("rotation_start resize failed"); exit(EXIT_FAILURE); } rot_record = resized; rot_record_cap = capacity; } for (u32 i = 0; i < rot_record_len; ++i) { u64 id = entity_id_from_pid(tr_action.item[i].pid); rot_record[i].pos = entity_world_pos(id); rot_record[i].rot = entity_world_rot(id); } } bool cancel = IsKeyPressed(KEY_ESCAPE) || !tr_action.item_cnt; if (cancel || IsKeyPressed(KEY_ENTER) || IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { if (cancel) { action_apply(&tr_action, true); action_free(&tr_action); tr_action = (EditorAction){0}; } else { action_commit(&tr_action); } rot_record_len = 0; started = false; ed.interact = ED_INTERACT_NONE; return; } if (IsKeyDown(KEY_LEFT_CONTROL)) { f32 wheel = -GetMouseWheelMove(); if (wheel > 0 && ed.rotation_snap > 0) { ed.rotation_snap--; } else if (wheel < 0 && ed.rotation_snap < 5) { ed.rotation_snap++; } } bool reset = false; if (IsKeyPressed(KEY_L)) { main_cam.local_space = !main_cam.local_space; reset = true; } if (IsKeyPressed(KEY_TAB)) { ed.rotate_group = !ed.rotate_group; reset = true; } int keys[3] = {KEY_X, KEY_Y, KEY_Z}; for (int i = 0; i < 3; ++i) { if (IsKeyPressed(keys[i])) { ed.rotate_axis = ed.rotate_axis == i ? -1 : i; reset = true; } } if (reset) { for (u32 i = 0; i < rot_record_len; ++i) { u64 id = entity_id_from_pid(tr_action.item[i].pid); Entity *ent = entity_get(id); assert(ent); entity_set_world_pos(ent, rot_record[i].pos); entity_set_world_rot(ent, rot_record[i].rot); entity_physics_transform_changed(id); } mouse_angle = 0; return; } bool local = main_cam.local_space && ed.sel_cnt == 1; Vec3 mouse_hit = {0}; if (ed.rotate_axis < 0) { if (!ray_plane_hit(ed.rotate_pivot, (Vec3){0, 1, 0}, &mouse_hit)) { return; } } else { mouse_angle += GetMouseDelta().x * 0.01f; } Vec3 axes[3] = { {1, 0, 0}, {0, 1, 0}, {0, 0, 1}, }; for (u32 i = 0; i < rot_record_len; ++i) { u64 id = entity_id_from_pid(tr_action.item[i].pid); Entity *ent = entity_get(id); assert(ent); Quaternion original = rot_record[i].rot; Quaternion basis = original; if (ed.rotate_group && ed.sel_cnt > 1) { basis = QuaternionIdentity(); } int axis_index = ed.rotate_axis < 0 ? 1 : ed.rotate_axis; Vec3 axis = axes[axis_index]; if (local) { axis = Vector3RotateByQuaternion(axis, basis); } /* A perpendicular reference: X uses Y, Y uses Z, Z uses X. */ Vec3 reference = axes[(axis_index + 1) % 3]; Vec3 forward = Vector3RotateByQuaternion(reference, basis); forward = Vector3Subtract(forward, Vector3Scale(axis, Vector3DotProduct(forward, axis))); if (Vector3LengthSqr(forward) < 0.0001f) { if (ed.rotate_axis < 0) { continue; } forward = Vector3RotateByQuaternion( axes[(axis_index + 2) % 3], basis); forward = Vector3Subtract(forward, Vector3Scale(axis, Vector3DotProduct(forward, axis))); } forward = Vector3Normalize(forward); f32 angle = mouse_angle; if (ed.rotate_axis < 0) { Vec3 pivot = ed.rotate_group ? ed.rotate_pivot : rot_record[i].pos; Vec3 direction = Vector3Subtract(mouse_hit, pivot); direction = Vector3Subtract(direction, Vector3Scale(axis, Vector3DotProduct(direction, axis))); if (Vector3LengthSqr(direction) < 0.0001f) { continue; } direction = Vector3Normalize(direction); angle = atan2f(Vector3DotProduct(axis, Vector3CrossProduct(forward, direction)), Vector3DotProduct(forward, direction)); } if (IsKeyDown(KEY_LEFT_CONTROL)) { f32 step = rotation_steps[ed.rotation_snap] * DEG2RAD; f32 heading = 0; if (!local) { heading = atan2f( Vector3DotProduct(axis, Vector3CrossProduct(reference, forward)), Vector3DotProduct(reference, forward)); } angle = roundf((heading + angle) / step) * step -heading; } Quaternion turn = QuaternionFromAxisAngle(axis, angle); Quaternion rotation = QuaternionNormalize( QuaternionMultiply(turn, original)); if (ed.rotate_group) { Vec3 offset = Vector3Subtract( rot_record[i].pos, ed.rotate_pivot); offset = Vector3RotateByQuaternion(offset, turn); entity_set_world_pos(ent, Vector3Add(ed.rotate_pivot, offset)); } entity_set_world_rot(ent, rotation); entity_physics_transform_changed(id); } } void editor_scale(void) { if (!tr_action.item) { tr_action = action_begin(ED_ACTION_TRANSFORM, true); ed.scale_pivot = selected_pivot(); ed.scale_factor = 1.0f; ed.scale_axis = -1; } bool cancel = IsKeyPressed(KEY_ESCAPE) || !tr_action.item_cnt; if (cancel || IsKeyPressed(KEY_ENTER) || IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { if (cancel) { action_apply(&tr_action, true); action_free(&tr_action); tr_action = (EditorAction){0}; } else { action_commit(&tr_action); } ed.interact = ED_INTERACT_NONE; return; } bool reset = false; if (IsKeyPressed(KEY_TAB)) { ed.scale_group = !ed.scale_group; reset = true; } int keys[3] = {KEY_X, KEY_Y, KEY_Z}; for (int i = 0; i < 3; ++i) { if (IsKeyPressed(keys[i])) { ed.scale_axis = ed.scale_axis == i ? -1 : i; reset = true; } } if (reset) { ed.scale_factor = 1.0f; } else { f32 movement = GetMouseDelta().x; if (movement == 0.0f) { return; } ed.scale_factor = Clamp( ed.scale_factor * exp2f(movement * 0.01f), 0.001f, 1000.0f); } Vec3 factors = { ed.scale_axis < 0 || ed.scale_axis == 0 ? ed.scale_factor : 1.0f, ed.scale_axis < 0 || ed.scale_axis == 1 ? ed.scale_factor : 1.0f, ed.scale_axis < 0 || ed.scale_axis == 2 ? ed.scale_factor : 1.0f, }; for (u32 i = 0; i < tr_action.item_cnt; ++i) { u64 id = entity_id_from_pid(tr_action.item[i].pid); Entity *ent = entity_get(id); assert(ent); EditorActionData *before = &tr_action.item[i].before; Vec3 position = before->transform.pos; if (ed.scale_group) { Vec3 pivot; if (!entity_point_to_local(ent->tr.parent, ed.scale_pivot, &pivot)) { continue; } Vec3 offset = Vector3Subtract(position, pivot); if (ed.scale_axis < 0) { offset = Vector3Scale(offset, ed.scale_factor); } else { Quaternion rotation = before->transform.rot; offset = Vector3RotateByQuaternion( offset, QuaternionInvert(rotation)); offset = Vector3Multiply(offset, factors); offset = Vector3RotateByQuaternion( offset, rotation); } position = Vector3Add(pivot, offset); } ent->tr.pos = position; ent->tr.scale = Vector3Multiply( before->transform.scale, factors); entity_physics_transform_changed(id); } } /* draw */ static void statusline(void) { char mode_str[16] = {0}; char ctx_str[256] = {0}; // Color bg = { 255, 255, 255, 150 }; Color bg = GetColor(GuiGetStyle(DEFAULT, BACKGROUND_COLOR)); cstr view_space = main_cam.local_space ? "Local" : "Global"; switch (ed.interact) { case ED_INTERACT_NONE: snprintf(mode_str, sizeof(mode_str), "EDITOR"); snprintf(ctx_str, sizeof(ctx_str), "%i selected | View: %s | " "F: focus, XYZ: axis, L: space " "P: projection, Del: delete", ed.sel_cnt, view_space); break; case ED_INTERACT_SELECT: snprintf(mode_str, 16, "SELECT"); break; case ED_INTERACT_CAMERA: snprintf(mode_str, 16, "VIEW"); snprintf(ctx_str, sizeof(ctx_str), "controls: " "shift: boost speed, scroll: adjust speed"); break; case ED_INTERACT_MOVE: { snprintf(mode_str, sizeof(mode_str), "MOVE"); cstr axes[3] = {"X", "Y", "Z"}; cstr planes[3] = {"YZ", "XZ", "XY"}; cstr constraint = "XZ"; bool local = main_cam.local_space && ed.sel_cnt == 1 && ed.move_axis >= 0; if (ed.move_axis >= 0) { constraint = ed.move_plane ? planes[ed.move_axis] : axes[ed.move_axis]; } if (IsKeyDown(KEY_LEFT_ALT)) { constraint = "Y"; local = false; } snprintf(ctx_str, sizeof(ctx_str), "%i selected | %s %s | Snap: %.3g %s | " "Ctrl+wheel: grid, L: space, XYZ: axis, " "Shift+XYZ: plane", ed.sel_cnt, local ? "Local" : "Global", constraint, ed.grid_sz, IsKeyDown(KEY_LEFT_CONTROL) ? "[on]" : "[off]"); break; } case ED_INTERACT_ROTATE: { cstr axes[3] = {"X", "Y", "Z"}; cstr constraint = ed.rotate_axis < 0 ? "Aim" : axes[ed.rotate_axis]; snprintf(mode_str, sizeof(mode_str), "ROTATE"); snprintf(ctx_str, sizeof(ctx_str), "%i | %s %s | %s | Snap %.0f deg %s | " "Tab: pivot, XYZ: axis, L: space, Ctrl+wheel: step", ed.sel_cnt, main_cam.local_space && ed.sel_cnt == 1 ? "Local" : "Global", constraint, ed.rotate_group ? "Center" : "Individual", rotation_steps[ed.rotation_snap], IsKeyDown(KEY_LEFT_CONTROL) ? "[on]" : "[off]"); break; } case ED_INTERACT_SCALE: { cstr axes[3] = {"Local X", "Local Y", "Local Z"}; cstr constraint = ed.scale_axis < 0 ? "Uniform" : axes[ed.scale_axis]; snprintf(mode_str, sizeof(mode_str), "SCALE"); snprintf(ctx_str, sizeof(ctx_str), "%i | %s %.3fx | %s | XYZ: axis, Tab: pivot", ed.sel_cnt, constraint, ed.scale_factor, ed.scale_group ? "Center" : "Individual"); break; } default: return; break; }; Color heading_bg = { 0, 0, 0, 25 }; if (ed.mode == ED_MODE_PHYSICS) { u64 id = entity_id_from_pid(ed.physics_pid); Entity *ent = entity_get(id); cstr name = ent && ent->name ? ent->name : ""; snprintf(mode_str, sizeof(mode_str), "PHYSICS"); heading_bg = (Color){70, 140, 210, 90}; if (ed.interact == ED_INTERACT_CAMERA) { snprintf(ctx_str, sizeof(ctx_str), "%s | MMB: pan, scroll: zoom", name); } else { snprintf(ctx_str, sizeof(ctx_str), "%s | View: %s [L] | RMB: orbit, scroll: zoom", name, view_space); } } /* statusline bg */ Rect rec = { // 0, 0, /* top bar */ 0, GetScreenHeight() - 25, GetScreenWidth(), 25 // editor_label_w(mode_str) , 25 }; DrawRectangleRec(rec, bg); /* mode str */ f32 mode_str_w = editor_label_w(mode_str); rec.width = 70; DrawRectangleRec(rec, heading_bg); GuiSetStyle(DEFAULT, TEXT_ALIGNMENT, TEXT_ALIGN_CENTER); GuiLabel(rec, mode_str); GuiSetStyle(DEFAULT, TEXT_ALIGNMENT, TEXT_ALIGN_LEFT); /* context str */ f32 ctx_str_w = editor_label_w(ctx_str); rec.x = 80; rec.width = ctx_str_w; GuiLabel(rec, ctx_str); rec.x += ctx_str_w; /* scene file status str */ char scene_str[96]; if (ed.history_save_csr == UINT32_MAX) { snprintf(scene_str, sizeof(scene_str), "| scene.fgs * - unsaved changes"); } else { u32 changes = ed.history_csr > ed.history_save_csr ? ed.history_csr - ed.history_save_csr : ed.history_save_csr - ed.history_csr; snprintf(scene_str, sizeof(scene_str), "| scene.fgs%s - %u change%s", changes ? " *" : "", changes, changes == 1 ? "" : "s"); } f32 scene_str_w = editor_label_w(scene_str); rec.x = GetScreenWidth() - scene_str_w - 5; rec.width = scene_str_w; DrawRectangleRec(rec, bg); GuiSetStyle(DEFAULT, TEXT_ALIGNMENT, TEXT_ALIGN_CENTER); GuiLabel(rec, scene_str); GuiSetStyle(DEFAULT, TEXT_ALIGNMENT, TEXT_ALIGN_LEFT); } /* Scene panel */ static void scene_collect_reparent(u64 id, EditorAction *action) { Entity *ent = entity_get(id); if (ent->pid && selection_root(ent)) { action->item[action->item_cnt++].pid = ent->pid; return; } for (u64 child = ent->tr.first_child; child;) { scene_collect_reparent(child, action); child = entity_get(child)->tr.next; } } static void scene_reparent(u64 parent_id) { Entity *parent = entity_get(parent_id); if (parent_id && (!parent || !parent->pid)) { return; } /* The destination cannot be within any selected subtree. */ for (u64 ancestor = parent_id; ancestor;) { Entity *ent = entity_get(ancestor); if (ent->ed.selected) { return; } ancestor = ent->tr.parent; } EditorAction action = { .tag = ED_ACTION_REPARENT, }; action.item = calloc(entity_count(), sizeof(*action.item)); if (!action.item) { perror("scene_reparent: action allocation failed."); exit(1); } for (u32 i = 0; i < entity_count(); ++i) { u64 id = entity_id_at(i); if (!entity_get(id)->tr.parent) { scene_collect_reparent(id, &action); } } /* Leave entities already under this parent in place. */ u32 count = 0; for (u32 i = 0; i < action.item_cnt; ++i) { u64 pid = action.item[i].pid; Entity *ent = entity_get(entity_id_from_pid(pid)); if (ent->tr.parent != parent_id) { action.item[count++].pid = pid; } } action.item_cnt = count; if (!action.item_cnt) { action_free(&action); return; } Matrix parent_world = entity_world_matrix(parent_id); f32 determinant = MatrixDeterminant(parent_world); if (!isfinite(determinant) || determinant == 0.0f) { TraceLog(LOG_WARNING, "Cannot reparent through a zero-scale transform."); action_free(&action); return; } Matrix parent_inverse = MatrixInvert(parent_world); Entity *first = parent ? entity_get(parent->tr.first_child) : NULL; /* Prepare every item before changing the hierarchy. */ for (u32 i = 0; i < action.item_cnt; ++i) { u64 id = entity_id_from_pid(action.item[i].pid); Entity *ent = entity_get(id); EditorActionData *before = &action.item[i].before; EditorActionData *after = &action.item[i].after; Entity *old_parent = entity_get(ent->tr.parent); Entity *prev = entity_get(ent->tr.prev); Entity *next = entity_get(ent->tr.next); before->reparent.parent = old_parent ? old_parent->pid : 0; before->reparent.prev = prev ? prev->pid : 0; before->reparent.next = next ? next->pid : 0; before->reparent.pos = ent->tr.pos; before->reparent.rot = ent->tr.rot; before->reparent.scale = ent->tr.scale; Matrix local = MatrixMultiply( entity_world_matrix(id), parent_inverse); Vec3 pos, scale; Quaternion rot; MatrixDecompose(local, &pos, &rot, &scale); rot = QuaternionNormalize(rot); Matrix rebuilt = MatrixMultiply( MatrixMultiply( MatrixScale(scale.x, scale.y, scale.z), QuaternionToMatrix(rot)), MatrixTranslate(pos.x, pos.y, pos.z)); float16 expected = MatrixToFloatV(local); float16 actual = MatrixToFloatV(rebuilt); for (int j = 0; j < 16; ++j) { f32 tolerance = 0.0001f * fmaxf(1.0f, fabsf(expected.v[j])); if (!isfinite(expected.v[j]) || !isfinite(actual.v[j]) || fabsf(expected.v[j] - actual.v[j]) > tolerance) { TraceLog(LOG_WARNING, "Cannot preserve all selected" " transforms."); action_free(&action); return; } } after->reparent.parent = parent ? parent->pid : 0; after->reparent.pos = pos; after->reparent.rot = rot; after->reparent.scale = scale; if (parent_id) { after->reparent.prev = i > 0 ? action.item[i - 1].pid : 0; after->reparent.next = i + 1 < action.item_cnt ? action.item[i + 1].pid : (first ? first->pid : 0); } } action_apply(&action, false); history_push(action); } static struct { u64 id; u64 hovered; u64 expand_id; Vec2 start; f32 expand_time; bool dragging; bool invalid; } scene_drag; static u64 scene_anchor_pid; static void scene_collect_visible(u64 id, u64 *rows, u32 *count) { Entity *ent = entity_get(id); if (ent->pid) { rows[(*count)++] = id; if (ent->ed.collapsed) { return; } } for (u64 child = ent->tr.first_child; child;) { scene_collect_visible(child, rows, count); child = entity_get(child)->tr.next; } } static void scene_select_range(u64 id, bool additive) { u64 *rows = malloc(entity_count() * sizeof(*rows)); if (!rows) { perror("scene_select_range: allocation failed."); exit(1); } u32 count = 0; for (u32 i = 0; i < entity_count(); ++i) { u64 root = entity_id_at(i); if (!entity_get(root)->tr.parent) { scene_collect_visible(root, rows, &count); } } int anchor = -1; int clicked = -1; for (u32 i = 0; i < count; ++i) { if (entity_get(rows[i])->pid == scene_anchor_pid) { anchor = (int)i; } if (rows[i] == id) { clicked = (int)i; } } assert(clicked >= 0); /* A missing or hidden anchor starts a new range here. */ if (anchor < 0) { anchor = clicked; scene_anchor_pid = entity_get(id)->pid; } if (!additive) { entities_clear_selected(); } int first = MIN(anchor, clicked); int last = MAX(anchor, clicked); for (int i = first; i <= last; ++i) { entity_get(rows[i])->ed.selected = true; } free(rows); } static void scene_entity(u64 id, Rect view, f32 *y, int depth, bool input) { Entity *ent = entity_get(id); if (!ent) { return; } /* Transient entities are hidden, but their children are visited. */ if (ent->pid) { Rect row = { view.x, *y, view.width, 26 }; bool hovered = input && CheckCollisionPointRec(GetMousePosition(), view) && CheckCollisionPointRec(GetMousePosition(), row); Rect arrow = { row.x + 8 + depth * 18, row.y, 18, row.height }; bool arrow_hovered = hovered && ent->tr.first_child && CheckCollisionPointRec(GetMousePosition(), arrow); if (arrow_hovered && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { ent->ed.collapsed = !ent->ed.collapsed; } if (hovered) { scene_drag.hovered = id; if (!arrow_hovered && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { bool ctrl = IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL); bool shift = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT); if (shift) { scene_select_range(id, ctrl); } else if (ctrl) { ent->ed.selected = !ent->ed.selected; scene_anchor_pid = ent->pid; } else { if (!ent->ed.selected) { entities_clear_selected(); ent->ed.selected = true; } scene_anchor_pid = ent->pid; scene_drag.id = id; scene_drag.start = GetMousePosition(); scene_drag.dragging = false; } } if (scene_drag.dragging) { for (u64 anc = id; anc;) { Entity *ancestor = entity_get(anc); if (ancestor->ed.selected) { scene_drag.invalid = true; break; } anc = ancestor->tr.parent; } } } if (row.y + row.height > view.y && row.y < view.y + view.height) { if (ent->ed.selected || hovered) { int colour = ent->ed.selected ? BASE_COLOR_PRESSED : BASE_COLOR_FOCUSED; DrawRectangleRec(row, GetColor(GuiGetStyle(DEFAULT, colour))); } if (ent->tr.first_child) { GuiLabel(arrow, ent->ed.collapsed ? "+" : "-"); } Rect label = { arrow.x + arrow.width, row.y, fmaxf(0, row.width - 26 - depth * 18), row.height }; if (hovered && scene_drag.dragging) { DrawRectangleLinesEx(row, 2.0f, scene_drag.invalid ? RED : GREEN); } if (label.width > 0) { GuiLabel(label, ent->name && ent->name[0] ? ent->name : "Entity"); } } *y += row.height; depth++; } if (ent->pid && ent->ed.collapsed) { return; } for (u64 child = ent->tr.first_child; child;) { Entity *next = entity_get(child); if (!next) { break; } u64 sibling = next->tr.next; scene_entity(child, view, y, depth, input); child = sibling; } } static void panel_scene(EditorPanelContainer container, bool input) { Rect view = {0}; Rect bounds = ed.panel[container].bounds; Vec2 *scroll = &ed.panel[container].scroll; input = input && ed.interact == ED_INTERACT_NONE; int state = GuiGetState(); if (!input) { GuiSetState(STATE_DISABLED); } if (input && state != STATE_DISABLED && IsMouseButtonPressed(MOUSE_LEFT_BUTTON)) { ed.focus_captured = true; } GuiPanel(bounds, "Scene"); f32 btn_w = editor_label_w("Create Entity") + 16; GuiSetStyle(DEFAULT, TEXT_ALIGNMENT, TEXT_ALIGN_CENTER); if (GuiButton( (Rect){ bounds.x + 8, bounds.y + 30, btn_w, 26 }, "Create Entity")) { EditorAction action = action_begin(ED_ACTION_CREATE, false); action_commit(&action); } GuiSetStyle(DEFAULT, TEXT_ALIGNMENT, TEXT_ALIGN_LEFT); GuiLabel((Rect){ bounds.x + 8, bounds.y + 64, 110, 20, }, "Draw Physics"); GuiCheckBox((Rect){ bounds.x + 126, bounds.y + 64, 20, 20, }, "", &ed.draw_physics); bounds.x += 8; bounds.y += 94; bounds.width -= 16; bounds.height -= 102; u32 count = 0; for (u32 i = 0; i < entity_count(); ++i) { Entity *ent = entity_get(entity_id_at(i)); if (!ent->pid) { continue; } bool hidden = false; for (u64 ancestor = ent->tr.parent; ancestor;) { Entity *parent = entity_get(ancestor); if (parent->pid && parent->ed.collapsed) { hidden = true; break; } ancestor = parent->tr.parent; } if (!hidden) { count++; } } f32 width = bounds.width - 2 * GuiGetStyle(DEFAULT, BORDER_WIDTH) - GuiGetStyle(LISTVIEW, SCROLLBAR_WIDTH); GuiScrollPanel(bounds, NULL, (Rect){ 0, 0, width, (count + 1) * 26.0f }, scroll, &view); bool tree_input = input && state != STATE_DISABLED && !GuiIsLocked(); Vec2 mouse = GetMousePosition(); bool inside = tree_input && CheckCollisionPointRec(mouse, view); if (scene_drag.dragging && inside && IsMouseButtonDown(MOUSE_LEFT_BUTTON)) { f32 direction = 0.0f; if (mouse.y < view.y + 24.0f) { direction = 1.0f; } else if (mouse.y > view.y + view.height - 24.0f) { direction = -1.0f; } f32 content_height = (count + 1) * 26.0f; f32 max_scroll = fmaxf(0.0f, content_height - view.height); scroll->y = Clamp( scroll->y + direction * 240.0f * GetFrameTime(), -max_scroll, 0.0f); } scene_drag.hovered = 0; scene_drag.invalid = false; if (scene_drag.id) { if (!tree_input || !entity_valid(scene_drag.id) || IsKeyPressed(KEY_ESCAPE)) { scene_drag.id = 0; scene_drag.dragging = false; } else if (IsMouseButtonDown(MOUSE_LEFT_BUTTON) && Vector2Distance(mouse, scene_drag.start) >= 5.0f) { scene_drag.dragging = true; } } BeginScissorMode(view.x, view.y, view.width, view.height); f32 y = view.y + scroll->y; Rect root_row = { view.x, y, view.width, 26 }; GuiLabel(root_row, "Scene Root"); y += root_row.height; for (u32 i = 0; i < entity_count(); ++i) { u64 id = entity_id_at(i); Entity *ent = entity_get(id); if (!ent->tr.parent) { scene_entity(id, view, &y, 0, tree_input); } } /* The root row and empty space both mean "make root". */ if (scene_drag.dragging && inside && !scene_drag.hovered) { DrawRectangleLinesEx(root_row, 2.0f, GREEN); } /* Unmodified clicks on empty space clear the selection. */ if (inside && !scene_drag.hovered && IsMouseButtonPressed(MOUSE_LEFT_BUTTON) && !IsKeyDown(KEY_LEFT_CONTROL) && !IsKeyDown(KEY_RIGHT_CONTROL) && !IsKeyDown(KEY_LEFT_SHIFT) && !IsKeyDown(KEY_RIGHT_SHIFT)) { entities_clear_selected(); scene_anchor_pid = 0; } EndScissorMode(); /* Apply changes after traversal has finished. */ if (scene_drag.id && IsMouseButtonReleased(MOUSE_LEFT_BUTTON)) { if (scene_drag.dragging) { if (inside && !scene_drag.invalid) { scene_reparent(scene_drag.hovered); } } else if (inside && scene_drag.hovered == scene_drag.id) { entities_clear_selected(); entity_get(scene_drag.id)->ed.selected = true; } scene_drag.id = 0; scene_drag.dragging = false; } Entity *target = entity_get(scene_drag.hovered); if (scene_drag.dragging && inside && !scene_drag.invalid && IsMouseButtonDown(MOUSE_LEFT_BUTTON) && target && target->ed.collapsed && target->tr.first_child) { if (scene_drag.expand_id != scene_drag.hovered) { scene_drag.expand_id = scene_drag.hovered; scene_drag.expand_time = 0.0f; } scene_drag.expand_time += GetFrameTime(); if (scene_drag.expand_time >= 0.6f) { target->ed.collapsed = false; } } else { scene_drag.expand_id = 0; scene_drag.expand_time = 0.0f; } GuiSetState(state); if (input && container != ED_PNL_CENTER && !IsMouseButtonDown(MOUSE_BUTTON_LEFT)) { ed.focus_captured = false; } } typedef struct { int focus; int next_id; bool input; char text[64]; } PanelEdit; // mixed means “show a blank field and start editing with an empty buffer”, // currently used for when there's multiple entities selected. static bool panel_field(PanelEdit *edit, Rect box, cstr text, bool mixed, bool numeric) { int id = edit->next_id++; bool editing = edit->focus == id; char display[sizeof(edit->text)]; snprintf(display, sizeof(display), "%s", text); bool toggle; if (numeric) { f32 draft = 0; toggle = GuiValueBoxFloat(box, NULL, editing ? edit->text : display, &draft, edit->input && editing); } else { toggle = GuiTextBox(box, editing ? edit->text : display, sizeof(edit->text), edit->input && editing); } if (!edit->input) { return false; } if (editing && IsKeyPressed(KEY_ESCAPE)) { edit->focus = 0; return false; } if (!toggle) { return false; } if (!editing) { memset(edit->text, 0, sizeof(edit->text)); // snprintf(edit->text, sizeof(edit->text), "%s", // mixed ? "" : display); edit->focus = id; return false; } edit->focus = 0; return IsKeyPressed(KEY_ENTER); } static bool panel_bool(Rect row, cstr label, bool *value, bool mixed) { GuiLabel((Rect){row.x, row.y, 40, row.height}, label); if (mixed) { *value = false; GuiLabel((Rect){ row.x + 68, row.y, row.width - 68, row.height, }, "(mixed)"); } return GuiCheckBox((Rect){ row.x + 40, row.y + (row.height - 20) * 0.5f, 20, 20, }, "", value); } static bool panel_float(PanelEdit *edit, Rect box, f32 *value, bool mixed) { char display[64] = ""; if (!mixed) { snprintf(display, sizeof(display), "%.3f", *value); } if (!panel_field(edit, box, display, mixed, true)) { return false; } char *end; f32 parsed = strtof(edit->text, &end); if (end == edit->text || *end != '\0') { return false; } *value = parsed; return true; } /* Returns the submitted axis, or -1 when nothing was submitted. */ static int panel_vec3(PanelEdit *edit, Rect row, cstr label, Vec3 *value, bool mixed) { GuiLabel((Rect){ row.x, row.y, 45, row.height }, label); f32 *axis[3] = { &value->x, &value->y, &value->z }; f32 width = (row.width - 45) / 3; int submitted = -1; for (int i = 0; i < 3; ++i) { Rect box = { row.x + 45 + i * width, row.y, width - 4, row.height }; if (panel_float(edit, box, axis[i], mixed)) { submitted = i; } } return submitted; } static void panel_properties(EditorPanelContainer container, bool input) { static PanelEdit edit; static bool add_open; static int add_choice; static int mat_idx; static bool shader_open; Rect shader_bounds = {0}; bool shader_visible = false; int shader_choice = 0; Rect bounds = ed.panel[container].bounds; int state = GuiGetState(); f32 pad = 8; input = input && ed.interact == ED_INTERACT_NONE && state != STATE_DISABLED; edit.next_id = 1; edit.input = input; if (!input) { add_open = false; edit.focus = 0; shader_open = false; GuiSetState(STATE_DISABLED); } if (add_open || shader_open) { GuiLock(); } GuiPanel(bounds, "Entity Properties"); if (!ed.sel_cnt) { edit.focus = 0; GuiSetStyle(DEFAULT, TEXT_ALIGNMENT, TEXT_ALIGN_CENTER); GuiLabel(bounds, "no entities selected"); GuiSetStyle(DEFAULT, TEXT_ALIGNMENT, TEXT_ALIGN_LEFT); goto reset; } Entity *ent = NULL; bool mixed = ed.sel_cnt > 1; if (!mixed) { for (u32 i = 0; i < entity_count(); ++i) { Entity *candidate = entity_get(entity_id_at(i)); if (candidate && candidate->pid && candidate->ed.selected) { ent = candidate; break; } } } /* selection info prep */ ModelCmp *model = NULL; int model_cnt = 0; int physics_cnt = 0; bool key_mixed = false; bool draw_mixed = false; for (u32 i = 0; i < entity_count(); ++i) { Entity *candidate = entity_get(entity_id_at(i)); if (!candidate->pid || !candidate->ed.selected) { continue; } physics_cnt += candidate->phys.has; if (!candidate->mdl.has) { continue; } model_cnt++; if (!model) { model = &candidate->mdl; } else { key_mixed |= strcmp(model->key, candidate->mdl.key) != 0; draw_mixed |= model->draw != candidate->mdl.draw; } } bool all_have_model = model_cnt == ed.sel_cnt; bool all_have_physics = physics_cnt == ed.sel_cnt; /* Name */ cstr name = mixed ? "" : (ent && ent->name ? ent->name : ""); f32 label_w = editor_label_w("Name"); Rect name_box = { bounds.x + pad, bounds.y + 30, label_w, 20 }; GuiLabel(name_box, "Name"); name_box.x += label_w + pad; name_box.width = bounds.x + bounds.width - name_box.x - pad; if (panel_field(&edit, name_box, name, mixed, false)) { editor_rename_selected(edit.text); } /* Transform */ Rect group = { bounds.x + pad, bounds.y + 60, bounds.width - 2 * pad, 90 + 2 * pad }; GuiGroupBox(group, "Transform"); Rect row = { group.x + pad, group.y + 15, group.width - 2 * pad, 20 }; Vec3 pos = ent ? ent->tr.pos : (Vec3){0}; int axis = panel_vec3(&edit, row, "pos", &pos, mixed); if (axis >= 0) { EditorAction action = action_begin(ED_ACTION_TRANSFORM, false); f32 value[3] = { pos.x, pos.y, pos.z }; for (u32 j = 0; j < action.item_cnt; ++j) { Entity *target = entity_get( entity_id_from_pid(action.item[j].pid) ); f32 *component[3] = { &target->tr.pos.x, &target->tr.pos.y, &target->tr.pos.z, }; *component[axis] = value[axis]; } action_commit(&action); } row.y += 30; Vec3 rot = ent ? QuaternionToEuler(ent->tr.rot) : (Vec3) {0}; rot.x *= RAD2DEG; rot.y *= RAD2DEG; rot.z *= RAD2DEG; axis = panel_vec3(&edit, row, "rot", &rot, mixed); if (axis >= 0) { EditorAction action = action_begin(ED_ACTION_TRANSFORM, false); f32 value[3] = { rot.x * DEG2RAD, rot.y * DEG2RAD, rot.z * DEG2RAD }; for (u32 j = 0; j < action.item_cnt; ++j) { Entity *target = entity_get( entity_id_from_pid(action.item[j].pid) ); Vec3 rt = QuaternionToEuler(target->tr.rot); f32 *component[3] = { &rt.x, &rt.y, &rt.z }; *component[axis] = value[axis]; target->tr.rot = QuaternionFromEuler(rt.x, rt.y, rt.z); } action_commit(&action); } row.y += 30; Vec3 scale = ent ? ent->tr.scale : (Vec3){0}; axis = panel_vec3(&edit, row, "scale", &scale, mixed); if (axis >= 0) { EditorAction action = action_begin(ED_ACTION_TRANSFORM, false); f32 value[3] = { scale.x, scale.y, scale.z }; for (u32 j = 0; j < action.item_cnt; ++j) { Entity *target = entity_get( entity_id_from_pid(action.item[j].pid) ); f32 *component[3] = { &target->tr.scale.x, &target->tr.scale.y, &target->tr.scale.z, }; *component[axis] = value[axis]; } action_commit(&action); } f32 next_y = group.y + group.height + 16; model_comp: if (!all_have_model) { goto physics_comp; } int material_count = 0; int first_material = 0; if (ent) { Asset *asset = asset_get(model->key); if (asset) { Model *mdl = &asset->mdl; material_count = mdl->materialCount; first_material = 1; for (int i = 0; i < mdl->meshCount; ++i) { if (mdl->meshMaterial[i] == 0) { first_material = 0; break; } } } } group.y = next_y; group.height = material_count ? 230 : 80; GuiGroupBox(group, "Model"); row = (Rect){ group.x + pad, group.y + 15, group.width - 2 * pad, 20, }; GuiSetStyle(DEFAULT, TEXT_ALIGNMENT, TEXT_ALIGN_CENTER); if (GuiButton((Rect){ group.x, group.y + group.height + pad, 120, 20 }, "Remove Model")) { edit.focus = 0; EditorAction action = action_begin(ED_ACTION_MODEL, false); for (u32 i = 0; i < action.item_cnt; ++i) { Entity *target = entity_get( entity_id_from_pid(action.item[i].pid) ); entity_model_free(&target->mdl); } action_commit(&action); goto reset; } GuiSetStyle(DEFAULT, TEXT_ALIGNMENT, TEXT_ALIGN_LEFT); GuiLabel((Rect){row.x, row.y, 32, row.height}, "Key"); if (GuiButton( (Rect){row.x + 40, row.y, row.width - 40, row.height}, key_mixed ? "" : GetFileName(model->key))) { edit.focus = 0; asset_picker_open(ASSET_TAG_MODEL, ASSET_PICK_MDL_COMP_UPDATE); } row.y += 30; bool draw = draw_mixed ? false : model->draw; if (panel_bool(row, "Draw", &draw, draw_mixed)) { edit.focus = 0; EditorAction action = action_begin(ED_ACTION_MODEL, false); for (u32 i = 0; i < action.item_cnt; ++i) { Entity *target = entity_get( entity_id_from_pid(action.item[i].pid) ); target->mdl.draw = draw; } action_commit(&action); } if (!material_count) { goto model_end; } row.y += 30; GuiLabel((Rect){ row.x, row.y, 64, row.height }, "Material"); Rect control = { row.x + 72, row.y, row.width - 72, row.height }; int previous = mat_idx; GuiSpinner(control, NULL, &mat_idx, first_material, material_count - 1, false); if (mat_idx != previous) { edit.focus = 0; } cstr texture_key = NULL; if ((u32)mat_idx < model->mat_cnt) { texture_key = model->materials[mat_idx].tex_key; } cstr texture_label = ""; if (texture_key) { texture_label = GetFileName(texture_key); } row.y += 30; control.y = row.y; GuiLabel((Rect){ row.x, row.y, 64, row.height }, "Texture"); Rect texture_button = control; texture_button.width -= 56; Rect reset_button = { control.x + control.width - 52, control.y, 52, control.height }; if (GuiButton(texture_button, texture_label)) { edit.focus = 0; asset_picker_open(ASSET_TAG_TEXTURE, ASSET_PICK_MAT_TEX); asset_picker.mat_idx = mat_idx; } if (GuiButton(reset_button, "Reset")) { edit.focus = 0; editor_set_material(NULL, mat_idx, MAT_PROP_TEXTURE); } row.y += 30; control.y = row.y; GuiLabel((Rect){ row.x, row.y, 64, row.height }, "Shader"); cstr shader_key = "default"; if ((u32)mat_idx < model->mat_cnt) { cstr key = model->materials[mat_idx].shader_key; if (key) { shader_key = key; } } int shader_count = sizeof(material_shader_keys) / sizeof(material_shader_keys[0]); for (int i = 0; i < shader_count; ++i) { if (strcmp(shader_key, material_shader_keys[i]) == 0) { shader_choice = i; break; } } shader_bounds = control; shader_visible = true; Vec2 uv[2] = { {1, 1}, {0, 0}, }; if ((u32)mat_idx < model->mat_cnt) { uv[0] = model->materials[mat_idx].uv_scale; uv[1] = model->materials[mat_idx].uv_offset; } cstr uv_labels[2] = {"Tiling", "Offset"}; for (int i = 0; i < 2; ++i) { row.y += 30; GuiLabel((Rect){ row.x, row.y, 64, row.height }, uv_labels[i]); f32 width = control.width / 2; f32 *values[2] = {&uv[i].x, &uv[i].y}; for (int axis = 0; axis < 2; ++axis) { Rect box = { control.x + axis * width, row.y, width - 4, row.height, }; f32 value = *values[axis]; if (!panel_float(&edit, box, &value, false)) { continue; } if (!isfinite(value) || value == *values[axis]) { continue; } EditorAction action = action_begin(ED_ACTION_MODEL, false); if ((u32)mat_idx >= model->mat_cnt) { u32 count = material_count; MaterialOverride *materials; materials = realloc(model->materials, count * sizeof(*materials)); if (!materials) { perror("material UV: alloc failed."); exit(1); } memset(materials + model->mat_cnt, 0, (count - model->mat_cnt) * sizeof(*materials)); for (u32 j = model->mat_cnt; j < count; ++j) { materials[j].uv_scale = (Vec2){1, 1}; } model->materials = materials; model->mat_cnt = count; } MaterialOverride *material; material = &model->materials[mat_idx]; Vec2 *target = i == 0 ? &material->uv_scale : &material->uv_offset; if (axis == 0) { target->x = value; } else { target->y = value; } *values[axis] = value; action_commit(&action); } } model_end: next_y = group.y + group.height + pad + 20 + 16; physics_comp: if (!all_have_physics) { goto comp_add; } group.y = next_y; group.height = 80; GuiGroupBox(group, "Physics"); row = (Rect){ group.x + pad, group.y + 15, group.width - 2 * pad, 20, }; GuiLabel(row, mixed ? "" : TextFormat("%u bodies, %u joints", ent->phys.body_cnt, ent->phys.joint_cnt)); row.y += 30; if (ent) { if (GuiButton(row, "Edit Physics")) { ed.scene_cam = main_cam; main_cam.allow_fly = false; main_cam.input = false; main_cam.update = false; ed.physics_pid = ent->pid; ed.physics_body = -1; ed.physics_shape = -1; ed.physics_scroll = (Vec2){0}; ed.prev_mode = ed.mode; ed.panel[ED_PNL_LEFT].enabled = true; ed.panel[ED_PNL_RIGHT].enabled = true; ed.mode = ED_MODE_PHYSICS; ed.panel[ED_PNL_LEFT].type = ED_PNL_TYPE_PHYSICS_ELEMENTS; ed.panel[ED_PNL_RIGHT].type = ED_PNL_TYPE_PHYSICS_PROPERTIES; editor_focus_selection(); edit.focus = 0; add_open = false; ed.focus = ED_PNL_NULL; ed.focus_captured = false; goto reset; } } else { GuiLabel(row, "Select one entity to edit physics"); } if (GuiButton((Rect){ group.x, group.y + group.height + pad, 120, 20, }, "Remove Physics")) { edit.focus = 0; EditorAction action = action_begin(ED_ACTION_PHYSICS, false); for (u32 i = 0; i < action.item_cnt; ++i) { u64 id = entity_id_from_pid(action.item[i].pid); Entity *target = entity_get(id); entity_physics_free(&target->phys); } action_commit(&action); goto reset; } next_y = group.y + group.height + pad + 20 + 16; comp_add: if (!shader_open) { GuiUnlock(); } row = (Rect){ bounds.x + pad, next_y, bounds.width - 2 * pad, 20, }; char choices[128] = "Add Component"; int model_choice = -1; int physics_choice = -1; int choice_cnt = 1; if (!all_have_model) { model_choice = choice_cnt++; strcat(choices, model_cnt ? ";Add Model to Missing" : ";Model"); } if (!all_have_physics) { physics_choice = choice_cnt++; strcat(choices, physics_cnt ? ";Add Physics to Missing" : ";Physics"); } int add_state = GuiGetState(); if (choice_cnt == 1) { add_open = false; GuiSetState(STATE_DISABLED); } bool toggle = GuiDropdownBox( row, choices, &add_choice, add_open); GuiSetState(add_state); if (!toggle) { goto reset; } add_open = !add_open; edit.focus = 0; if (add_open) { goto reset; } int choice = add_choice; add_choice = 0; if (choice == model_choice) { asset_picker_open(ASSET_TAG_MODEL, ASSET_PICK_MDL_COMP_ADD); goto reset; } if (choice != physics_choice) { goto reset; } EditorAction action = action_begin(ED_ACTION_PHYSICS, false); for (u32 i = 0; i < action.item_cnt; ++i) { Entity *target = entity_get( entity_id_from_pid(action.item[i].pid) ); if (target->phys.has) { continue; } target->phys.has = true; } action_commit(&action); reset: GuiUnlock(); if (!shader_visible) { shader_open = false; } else { if (add_open) { GuiLock(); } int previous = shader_choice; if (GuiDropdownBox(shader_bounds, material_shader_labels, &shader_choice, shader_open)) { shader_open = !shader_open; edit.focus = 0; if (!shader_open && shader_choice != previous) { cstr key = material_shader_keys[shader_choice]; editor_set_material(key, mat_idx, MAT_PROP_SHADER); } } GuiUnlock(); } GuiSetState(state); GuiSetStyle(DEFAULT, TEXT_ALIGNMENT, TEXT_ALIGN_LEFT); if (input && container != ED_PNL_CENTER) { ed.focus_captured = edit.focus != 0 || add_open || shader_open || ed.panel[ED_PNL_CENTER].enabled; } } static void panel_space(EditorPanelContainer container) { static cstr strs[][2] = { { "Q", "Toggle left panel" }, { "E", "Toggle right panel" }, { "/", "Command Menu" }, }; static const int str_cnt = sizeof(strs) / sizeof(strs[0]); Rect bounds = { .x = (f32)GetScreenWidth() / 2 - 125, .y = (f32)GetScreenHeight() / 2 - 65, .width = 250, .height = 130, }; Color bg = GetColor(GuiGetStyle(DEFAULT, BACKGROUND_COLOR)); // GuiPanel(bounds, "Space Menu"); DrawRectangleRec(bounds, bg); GuiGroupBox((Rect) { .x = bounds.x + 5, .y = bounds.y + 10, .width = bounds.width - 10, .height = bounds.height - 15, }, "Space Menu"); Rect lrec = bounds; lrec.height = 20; lrec.x += 16; for (int i = 0; i < str_cnt; ++i) { lrec.y += 30; GuiLabel(lrec, strs[i][0]); lrec.x += 50; GuiLabel(lrec, strs[i][1]); lrec.x -= 50; } } /* model picker */ static bool editor_set_model(cstr key, AssetPickerMode mode) { Asset *asset = asset_get(key); if (!asset || asset->tag != ASSET_TAG_MODEL) { return false; } EditorAction action = action_begin(ED_ACTION_MODEL, false); for (u32 i = 0; i < action.item_cnt; ++i) { u64 id = entity_id_from_pid(action.item[i].pid); Entity *ent = entity_get(id); if ((mode == ASSET_PICK_MDL_COMP_ADD&& ent->mdl.has) || (mode == ASSET_PICK_MDL_COMP_UPDATE && !ent->mdl.has)) { continue; } bool draw = ent->mdl.has ? ent->mdl.draw : true; entity_model_free(&ent->mdl); ent->mdl = (ModelCmp){ .key = strdup(key), .mdl = &asset->mdl, .has = true, .draw = draw, }; } action_commit(&action); return true; } static void editor_set_material(cstr key, u32 mat_idx, MaterialProperty prop) { EditorAction action = action_begin(ED_ACTION_MODEL, false); for (u32 i = 0; i < action.item_cnt; ++i) { u64 id = entity_id_from_pid(action.item[i].pid); Entity *ent = entity_get(id); ModelCmp *model = &ent->mdl; if (!model->has) { continue; } if (!key && mat_idx >= model->mat_cnt) { continue; } if (!model->mdl) { Asset *asset = asset_get(model->key); if (!asset) { continue; } model->mdl = &asset->mdl; } if (mat_idx >= (u32)model->mdl->materialCount) { continue; } if (mat_idx >= model->mat_cnt) { u32 count = model->mdl->materialCount; MaterialOverride *materials; materials = realloc(model->materials, count * sizeof(*materials)); if (!materials) { perror("editor_set_texture: alloc failed."); exit(1); } memset(materials + model->mat_cnt, 0, (count - model->mat_cnt) * sizeof(*materials)); for (u32 i = model->mat_cnt; i < count; ++i) { materials[i].uv_scale = (Vec2){1, 1}; } model->materials = materials; model->mat_cnt = count; } MaterialOverride *material; material = &model->materials[mat_idx]; char **field = prop == MAT_PROP_TEXTURE ? &material->tex_key : &material->shader_key; char *replacement = key ? strdup(key) : NULL; free(*field); *field = replacement; } action_commit(&action); } /* qsort compare func */ static int model_choice_compare(const void *a, const void *b) { const AssetChoice *left = a; const AssetChoice *right = b; if (left->score > right->score) { return -1; } if (left->score < right->score) { return 1; } int order = strcmp(GetFileName(left->key), GetFileName(right->key)); if (order != 0) { return order; } return strcmp(left->key, right->key); } static void asset_picker_close(void) { if (asset_picker.preview.id) { UnloadRenderTexture(asset_picker.preview); } free(asset_picker.items); memset(&asset_picker, 0, sizeof(asset_picker)); ed.panel[ED_PNL_CENTER].enabled = false; ed.focus = ED_PNL_NULL; ed.focus_captured = false; } static void asset_picker_open(AssetTag tag, AssetPickerMode mode) { asset_picker.tag = tag; asset_picker.mode = mode; char **keys = asset_type_keys(tag, &asset_picker.item_cnt); if (asset_picker.item_cnt) { asset_picker.items = calloc(asset_picker.item_cnt, sizeof(*asset_picker.items)); if (!asset_picker.items) { perror("asset_picker_open: alloc failed."); exit(1); } } for (int i = 0; i < asset_picker.item_cnt; ++i) { asset_picker.items[i].key = keys[i]; } free(keys); asset_picker.match_cnt = asset_picker.item_cnt; if (asset_picker.item_cnt > 1) { qsort(asset_picker.items, asset_picker.item_cnt, sizeof(*asset_picker.items), model_choice_compare); } ed.panel[ED_PNL_CENTER].type = ED_PNL_TYPE_ASSET_PICKER; ed.panel[ED_PNL_CENTER].enabled = true; ed.focus = ED_PNL_CENTER; ed.focus_captured = true; } static void model_picker_preview(Rect bounds, Model mdl) { if (asset_picker.preview.texture.width != bounds.width || asset_picker.preview.texture.height != bounds.height) { if (asset_picker.preview.texture.id) { UnloadRenderTexture(asset_picker.preview); } asset_picker.preview = LoadRenderTexture(bounds.width, bounds.height); } Matrix transform = mdl.transform; mdl.transform = MatrixIdentity(); BoundingBox bb = GetModelBoundingBox(mdl); Vec3 center = Vector3Scale(Vector3Add(bb.min, bb.max), 0.5); center = Vector3Transform(center, transform); f32 radius = 0.001f; for (int i = 0; i < 8; ++i) { Vec3 corner = { (i & 1) ? bb.max.x : bb.min.x, (i & 2) ? bb.max.y : bb.min.y, (i & 4) ? bb.max.z : bb.min.z }; corner = Vector3Transform(corner, transform); radius = fmaxf(radius, Vector3Distance(corner, center)); } transform = MatrixMultiply(transform, MatrixTranslate(-center.x, -center.y, -center.z)); transform = MatrixMultiply(transform, MatrixScale(1.0/radius, 1.0/radius, 1.0/radius)); f32 dist = 3.0f * fmaxf(1.0f, (f32)bounds.height / (f32)bounds.width); Camera3D cam = { .position = Vector3Scale(Vector3Normalize((Vec3){1,0.7,1}), dist), .target = {0}, .up = {0,1,0}, .fovy = 45, .projection = CAMERA_PERSPECTIVE, }; BeginTextureMode(asset_picker.preview); ClearBackground((Color){ 35, 35, 40, 255 }); BeginMode3D(cam); /* we do this bc our normal draw calls override default shader */ for (int i = 0; i < mdl.meshCount; ++i) { Material mat = mdl.materials[mdl.meshMaterial[i]]; mat.shader = (Shader) { .id = rlGetShaderIdDefault(), .locs = rlGetShaderLocsDefault(), }; DrawMesh(mdl.meshes[i], mat, transform); } EndMode3D(); EndTextureMode(); DrawTexturePro(asset_picker.preview.texture, (Rect) { 0, 0, bounds.width, -bounds.height, }, bounds, (Vec2){0}, 0, WHITE); } static void texture_picker_preview(Rect bounds, Texture tex) { if (bounds.width <= 0 || bounds.height <= 0) { return; } f32 scale = fminf(bounds.width / tex.width, bounds.height / tex.height); Rect dest = { .width = tex.width * scale, .height = tex.height * scale, }; dest.x = bounds.x + (bounds.width - dest.width) * 0.5f; dest.y = bounds.y + (bounds.height - dest.height) * 0.5f; DrawTexturePro(tex, (Rect){0, 0, tex.width, tex.height}, dest, (Vec2){0}, 0, WHITE); } static cstr panel_asset_picker(Rect bounds, bool input) { if (input && IsKeyPressed(KEY_ESCAPE)) { asset_picker_close(); return NULL; } GuiPanel(bounds, "Choose asset"); f32 left_width = floorf((bounds.width - 24) * 0.45f); Rect search = { bounds.x + 8, bounds.y + 30, left_width, 24, }; Rect list = { search.x, search.y + 32, left_width, fmaxf(24, bounds.height - 100), }; Rect preview = { search.x + left_width + 8, search.y, bounds.width - left_width - 24, bounds.height - 38, }; char previous[sizeof(asset_picker.query)]; memcpy(previous, asset_picker.query, sizeof(previous)); /* Ignore the toggle result: the search always keeps text focus. */ GuiTextBox(search, asset_picker.query, sizeof(asset_picker.query), input); if (strcmp(previous, asset_picker.query) != 0) { asset_picker.match_cnt = 0; for (int i = 0; i < asset_picker.item_cnt; ++i) { AssetChoice *item = &asset_picker.items[i]; item->score = fuzzy_match(asset_picker.query, GetFileName(item->key)); if (item->score != INT32_MIN) { asset_picker.match_cnt++; } } if (asset_picker.item_cnt > 1) { qsort(asset_picker.items, asset_picker.item_cnt, sizeof(*asset_picker.items), model_choice_compare); } asset_picker.sel_idx = 0; asset_picker.scroll = 0; } if (input && asset_picker.match_cnt) { int step = 0; if (IsKeyPressed(KEY_UP)) { step = -1; } else if (IsKeyPressed(KEY_DOWN)) { step = 1; } else if (IsKeyPressed(KEY_TAB)) { bool backwards = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT); step = backwards ? -1 : 1; } asset_picker.sel_idx = (asset_picker.sel_idx + step + asset_picker.match_cnt) % asset_picker.match_cnt; } int visible = MAX(1, (int)(list.height / 24)); if (asset_picker.sel_idx < asset_picker.scroll) { asset_picker.scroll = asset_picker.sel_idx; } if (asset_picker.sel_idx >= asset_picker.scroll + visible) { asset_picker.scroll = asset_picker.sel_idx - visible + 1; } BeginScissorMode((int)list.x, (int)list.y, (int)list.width, (int)list.height); for (int row = 0; row < visible; ++row) { int index = asset_picker.scroll + row; if (index >= asset_picker.match_cnt) { break; } Rect item_box = { list.x, list.y + row * 24, list.width, 24, }; if (index == asset_picker.sel_idx) { DrawRectangleRec(item_box, GetColor(GuiGetStyle(LISTVIEW, BASE_COLOR_PRESSED))); } GuiLabel(item_box, GetFileName(asset_picker.items[index].key)); } if (!asset_picker.match_cnt) { GuiLabel((Rect) { list.x, list.y, list.width, 24 }, "No matching assets"); } EndScissorMode(); GuiPanel(preview, NULL); if (asset_picker.match_cnt) { AssetChoice *item = &asset_picker.items[asset_picker.sel_idx]; if (!item->tried_loading) { item->asset = asset_get(item->key); item->tried_loading = true; } /* preview */ if (item->asset) { Rect image = { preview.x + 1, preview.y + 34, preview.width - 2, preview.height - 35, }; switch (asset_picker.tag) { case ASSET_TAG_MODEL: model_picker_preview(image, item->asset->mdl); break; case ASSET_TAG_TEXTURE: texture_picker_preview(image, item->asset->tex); break; default: GuiLabel(image, "No preview available"); break; } } GuiLabel((Rect){ preview.x + 8, preview.y + 8, preview.width - 16, 24, }, item->asset ? GetFileName(item->key) : "Could not load asset"); if (input && item->asset && IsKeyPressed(KEY_ENTER)) { return item->key; } } GuiLabel((Rect){ list.x, bounds.y + bounds.height - 30, list.width, 22, }, "Tab: cycle Enter: add Esc: cancel"); return NULL; } static void panel_physics_elements(EditorPanelContainer container) { Rect bounds = ed.panel[container].bounds; GuiPanel(bounds, "Physics Elements"); Entity *ent = entity_get(entity_id_from_pid(ed.physics_pid)); if (!ent) { return; } PhysicsCmp *phys = &ent->phys; Rect row = { bounds.x + 8, bounds.y + 30, bounds.width - 16, 20, }; if (GuiButton(row, "Add Body")) { EditorAction action = action_begin(ED_ACTION_PHYSICS, false); entity_physics_clear(phys); PhysicsBody *bodies = realloc( phys->bodies, (phys->body_cnt + 1) * sizeof(*bodies)); if (!bodies) { perror("physics body allocation failed"); exit(EXIT_FAILURE); } phys->bodies = bodies; phys->bodies[phys->body_cnt] = (PhysicsBody) { .rot = QuaternionIdentity(), .gravity_scale = 1.0f, .mode = PHYS_MODE_DYNAMIC, .enable_sleep = true, }; if (!phys->body_cnt) { phys->root_body = 0; } ed.physics_body = (int)phys->body_cnt; ed.physics_shape = -1; phys->body_cnt++; action_commit(&action); } Rect list = { bounds.x + 8, bounds.y + 60, bounds.width - 16, bounds.height - 68, }; Rect view = {0}; f32 width = list.width - 2 * GuiGetStyle(DEFAULT, BORDER_WIDTH) - GuiGetStyle(LISTVIEW, SCROLLBAR_WIDTH); u32 row_count = phys->body_cnt; for (u32 i = 0; i < phys->body_cnt; ++i) { row_count += phys->bodies[i].shape_cnt; } GuiScrollPanel(list, NULL, (Rect){0, 0, width, row_count * 26.0f}, &ed.physics_scroll, &view); bool input = GuiGetState() != STATE_DISABLED && !GuiIsLocked(); Vec2 mouse = GetMousePosition(); if (input && IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && CheckCollisionPointRec(mouse, view)) { ed.physics_body = -1; ed.physics_shape = -1; } BeginScissorMode( (int)view.x, (int)view.y, (int)view.width, (int)view.height); cstr shape_names[] = { "Box", "Sphere", "Capsule", "Cylinder", "Convex Hull", "Triangle Mesh", }; f32 y = view.y + ed.physics_scroll.y; for (u32 i = 0; i < phys->body_cnt; ++i) { PhysicsBody *body = &phys->bodies[i]; for (int j = -1; j < (int)body->shape_cnt; ++j) { row = (Rect){view.x, y, width, 26}; y += 26; bool hovered = input && CheckCollisionPointRec(mouse, view) && CheckCollisionPointRec(mouse, row); if (hovered && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { ed.physics_body = (int)i; ed.physics_shape = j; } bool selected = ed.physics_body == (int)i && ed.physics_shape == j; if (selected || hovered) { int colour = selected ? BASE_COLOR_PRESSED : BASE_COLOR_FOCUSED; DrawRectangleRec(row, GetColor( GuiGetStyle(DEFAULT, colour))); } f32 indent = j < 0 ? 8 : 26; row.x += indent; row.width -= indent; if (j < 0) { GuiLabel(row, TextFormat("Body %u%s", i + 1, i == phys->root_body ? " (root)" : "")); } else { GuiLabel(row, TextFormat("%s %i", shape_names[body->shapes[j].tag], j + 1)); } } } EndScissorMode(); } static void panel_physics_shape(PanelEdit *edit, Rect row, Entity *ent, PhysicsBody *body) { u32 index = (u32)ed.physics_shape; if (index >= body->shape_cnt) { edit->focus = 0; return; } PhysicsShape *shape = &body->shapes[index]; GuiLabel(row, TextFormat("Body %i | Shape %u", ed.physics_body + 1, index + 1)); row.y += 30; if (GuiButton(row, "Remove Shape")) { edit->focus = 0; EditorAction action = action_begin(ED_ACTION_PHYSICS, false); entity_physics_clear(&ent->phys); switch (shape->tag) { case PHYS_SHAPE_CONVEX_HULL: free(shape->ConvexHull.points); break; case PHYS_SHAPE_STATIC_TRIMESH: free(shape->TriMesh.points); free(shape->TriMesh.indices); break; default: break; } memmove(shape, shape + 1, (body->shape_cnt - index - 1) * sizeof(*shape)); body->shape_cnt--; if (!body->shape_cnt) { free(body->shapes); body->shapes = NULL; } ed.physics_shape = -1; action_commit(&action); return; } Vec3 values[3] = { shape->pos, Vector3Scale(QuaternionToEuler(shape->rot), RAD2DEG), {0}, }; cstr labels[3] = {"Pos", "Rot", "Size"}; bool changed[3] = {false}; int count = 2; if (shape->tag == PHYS_SHAPE_BOX) { values[2] = shape->Box.sz; count = 3; } for (int i = 0; i < count; ++i) { row.y += 30; Vec3 value = values[i]; int axis = panel_vec3(edit, row, labels[i], &value, false); if (axis < 0) { continue; } if (!isfinite(value.x) || !isfinite(value.y) || !isfinite(value.z)) { continue; } if (i == 2 && (value.x <= 0 || value.y <= 0 || value.z <= 0)) { continue; } changed[i] = !Vector3Equals(value, values[i]); values[i] = value; } f32 total_height = 0; if (shape->tag == PHYS_SHAPE_CAPSULE) { total_height = shape->Capsule.height + 2.0f * shape->Capsule.radius; } f32 radius = 0; bool radius_changed = false; if (shape->tag == PHYS_SHAPE_SPHERE || shape->tag == PHYS_SHAPE_CAPSULE || shape->tag == PHYS_SHAPE_CYLINDER) { row.y += 30; GuiLabel((Rect){ row.x, row.y, 60, row.height, }, "Radius"); Rect box = { row.x + 60, row.y, row.width - 60, row.height, }; f32 orig_radius; if (shape->tag == PHYS_SHAPE_SPHERE) { orig_radius = shape->Sphere.radius; } else if (shape->tag == PHYS_SHAPE_CAPSULE) { orig_radius = shape->Capsule.radius; } else { orig_radius = shape->Cylinder.radius; } radius = orig_radius; if (panel_float(edit, box, &radius, false)) { radius_changed = isfinite(radius) && radius > 0 && radius != orig_radius; if (shape->tag == PHYS_SHAPE_CAPSULE && radius > total_height * 0.5f) { radius_changed = false; } } } f32 height = 0; bool height_changed = false; if (shape->tag == PHYS_SHAPE_CAPSULE || shape->tag == PHYS_SHAPE_CYLINDER) { row.y += 30; GuiLabel((Rect){ row.x, row.y, 100, row.height, }, "Height"); Rect box = { row.x + 100, row.y, row.width - 100, row.height, }; f32 original_height = shape->tag == PHYS_SHAPE_CAPSULE ? total_height : shape->Cylinder.height; height = original_height; if (panel_float(edit, box, &height, false)) { height_changed = isfinite(height) && height > 0 && height != original_height; if (shape->tag == PHYS_SHAPE_CAPSULE) { f32 effective_radius = radius_changed ? radius : shape->Capsule.radius; if (height < 2.0f * effective_radius) { height_changed = false; } } } } cstr material_labels[3] = { "Density", "Friction", "Restitution", }; f32 material[3] = { shape->density, shape->friction, shape->restitution, }; bool material_changed = false; for (int i = 0; i < 3; ++i) { row.y += 30; GuiLabel((Rect){ row.x, row.y, 100, row.height, }, material_labels[i]); Rect box = { row.x + 100, row.y, row.width - 100, row.height, }; f32 value = material[i]; if (!panel_float(edit, box, &value, false)) { continue; } if (!isfinite(value) || value < 0) { continue; } if (i == 2 && value > 1) { continue; } material_changed |= value != material[i]; material[i] = value; } if (!changed[0] && !changed[1] && !changed[2] && !radius_changed && !height_changed && !material_changed) { return; } EditorAction action = action_begin(ED_ACTION_PHYSICS, false); entity_physics_clear(&ent->phys); if (changed[0]) { shape->pos = values[0]; } if (changed[1]) { shape->rot = QuaternionFromEuler( values[1].x * DEG2RAD, values[1].y * DEG2RAD, values[1].z * DEG2RAD); } if (changed[2]) { shape->Box.sz = values[2]; } if (shape->tag == PHYS_SHAPE_SPHERE && radius_changed) { shape->Sphere.radius = radius; } if (shape->tag == PHYS_SHAPE_CAPSULE && (radius_changed || height_changed)) { f32 new_radius = radius_changed ? radius : shape->Capsule.radius; f32 new_height = height_changed ? height : total_height; shape->Capsule.radius = new_radius; shape->Capsule.height = fmaxf(0, new_height - 2.0f * new_radius); } if (shape->tag == PHYS_SHAPE_CYLINDER) { if (radius_changed) { shape->Cylinder.radius = radius; } if (height_changed) { shape->Cylinder.height = height; } } shape->density = material[0]; shape->friction = material[1]; shape->restitution = material[2]; action_commit(&action); } static void physics_shape_model_geometry( PhysicsShape *shape, const Model *model, u32 point_cnt, u32 idx_cnt) { bool trimesh = shape->tag == PHYS_SHAPE_STATIC_TRIMESH; Vec3 *points = malloc(point_cnt * sizeof(*points)); u32 *indices = NULL; if (trimesh) { indices = malloc(idx_cnt * sizeof(*indices)); } if (!points || (trimesh && !indices)) { perror("model collision allocation failed"); exit(EXIT_FAILURE); } u32 point_offset = 0; u32 index_offset = 0; for (int i = 0; i < model->meshCount; ++i) { const Mesh *mesh = &model->meshes[i]; for (int v = 0; v < mesh->vertexCount; ++v) { points[point_offset + v] = (Vec3){ mesh->vertices[v * 3], mesh->vertices[v * 3 + 1], mesh->vertices[v * 3 + 2], }; } if (trimesh) { u32 count = (u32)mesh->triangleCount * 3; for (u32 k = 0; k < count; ++k) { u32 index = mesh->indices ? (u32)mesh->indices[k] : k; indices[index_offset++] = point_offset + index; } } point_offset += (u32)mesh->vertexCount; } if (trimesh) { shape->TriMesh.points = points; shape->TriMesh.indices = indices; shape->TriMesh.point_cnt = point_cnt; shape->TriMesh.idx_cnt = idx_cnt; } else { shape->ConvexHull.points = points; shape->ConvexHull.point_cnt = point_cnt; } } static void panel_physics_properties(EditorPanelContainer container, bool input) { Rect bounds = ed.panel[container].bounds; static bool type_open; static PanelEdit edit; edit.next_id = 1; edit.input = input && !type_open; if (!edit.input) { edit.focus = 0; } if (!input) { type_open = false; } if (type_open) { GuiLock(); } GuiPanel(bounds, "Physics Properties"); Rect button = { bounds.x + 8, bounds.y + 30, bounds.width - 16, 20, }; if (GuiButton(button, "Done")) { main_cam = ed.scene_cam; edit.focus = 0; ed.mode = ed.prev_mode; ed.physics_pid = 0; ed.panel[ED_PNL_LEFT].type = ED_PNL_TYPE_SCENE; ed.panel[ED_PNL_RIGHT].type = ED_PNL_TYPE_PROPERTIES; ed.focus = ED_PNL_NULL; ed.focus_captured = false; return; } Entity *ent = entity_get(entity_id_from_pid(ed.physics_pid)); Rect info = { bounds.x + 8, bounds.y + 60, bounds.width - 16, 20, }; if (ent && ed.physics_body >= 0 && (u32)ed.physics_body < ent->phys.body_cnt) { PhysicsBody *body = &ent->phys.bodies[ed.physics_body]; if (ed.physics_shape >= 0) { type_open = false; GuiUnlock(); panel_physics_shape(&edit, info, ent, body); goto reset; } GuiLabel(info, TextFormat("Body %i | %u shapes", ed.physics_body + 1, body->shape_cnt)); info.y += 30; if (ent->phys.root_body == (u32)ed.physics_body) { GuiLabel(info, "Root body"); } else if (GuiButton(info, "Make Root Body")) { edit.focus = 0; EditorAction action = action_begin(ED_ACTION_PHYSICS, false); ent->phys.root_body = (u32)ed.physics_body; action_commit(&action); } info.y += 30; if (GuiButton(info, "Remove Body")) { edit.focus = 0; EditorAction action = action_begin(ED_ACTION_PHYSICS, false); PhysicsCmp *phys = &ent->phys; u32 index = (u32)ed.physics_body; entity_physics_clear(phys); for (u32 i = 0; i < body->shape_cnt; ++i) { PhysicsShape *shape = &body->shapes[i]; switch (shape->tag) { case PHYS_SHAPE_CONVEX_HULL: free(shape->ConvexHull.points); break; case PHYS_SHAPE_STATIC_TRIMESH: free(shape->TriMesh.points); free(shape->TriMesh.indices); break; default: break; } } free(body->shapes); memmove(body, body + 1, (phys->body_cnt - index - 1) * sizeof(*body)); phys->body_cnt--; if (phys->body_cnt == index) { phys->root_body = 0; } else if (phys->root_body > index) { phys->root_body -= 1; } u32 kept = 0; for (u32 i = 0; i < phys->joint_cnt; ++i) { PhysicsJoint joint = phys->joints[i]; if (joint.body_a == index || joint.body_b == index) { continue; } if (joint.body_a > index) { joint.body_a--; } if (joint.body_b > index) { joint.body_b--; } phys->joints[kept++] = joint; } phys->joint_cnt = kept; if (!phys->body_cnt) { free(phys->bodies); phys->bodies = NULL; } if (!phys->joint_cnt) { free(phys->joints); phys->joints = NULL; } ed.physics_body = -1; ed.physics_shape = -1; action_commit(&action); goto reset; } info.y += 30; Vec3 pos = body->pos; int pos_axis = panel_vec3( &edit, info, "Pos", &pos, false); bool pos_changed = pos_axis >= 0 && isfinite(pos.x) && isfinite(pos.y) && isfinite(pos.z) && !Vector3Equals(pos, body->pos); info.y += 30; Vec3 original_rot = Vector3Scale( QuaternionToEuler(body->rot), RAD2DEG); Vec3 rot = original_rot; int rot_axis = panel_vec3( &edit, info, "Rot", &rot, false); bool rot_changed = rot_axis >= 0 && isfinite(rot.x) && isfinite(rot.y) && isfinite(rot.z) && !Vector3Equals(rot, original_rot); info.y += 30; GuiLabel((Rect){ info.x, info.y, 45, info.height, }, "Type"); Rect type_box = { info.x + 45, info.y, info.width - 45, info.height, }; info.y += 30; bool sleep = body->enable_sleep; panel_bool(info, "Enable Sleep", &sleep, false); f32 values[3] = { body->gravity_scale, body->linear_damping, body->angular_damping, }; cstr labels[3] = { "Gravity scale", "Linear damping", "Angular damping", }; bool floats_changed = false; for (int i = 0; i < 3; ++i) { info.y += 30; GuiLabel((Rect){ info.x, info.y, 130, info.height, }, labels[i]); Rect box = { info.x + 130, info.y, info.width - 130, info.height, }; f32 value = values[i]; if (panel_float(&edit, box, &value, false)) { if (!isfinite(value)) { continue; } if (i > 0 && value < 0) { continue; } floats_changed |= value != values[i]; values[i] = value; } } info.y += 30; bool add_box = GuiButton(info, "Add Box"); info.y += 30; bool add_sphere = GuiButton(info, "Add Sphere"); info.y += 30; bool add_capsule = GuiButton(info, "Add Capsule"); info.y += 30; bool add_cylinder = GuiButton(info, "Add Cylinder"); info.y += 30; int button_state = GuiGetState(); if (!ent->mdl.has) { GuiSetState(STATE_DISABLED); } bool fit_model = GuiButton(info, "Add Model Box"); info.y += 30; bool add_hull = GuiButton(info, "Add Model Hull"); info.y += 30; if (body->mode != PHYS_MODE_STATIC) { GuiSetState(STATE_DISABLED); } bool add_trimesh = GuiButton(info, "Add Model Trimesh"); GuiSetState(button_state); Model *model = NULL; if (fit_model || add_hull || add_trimesh) { Asset *asset = asset_get(ent->mdl.key); if (asset && asset->tag == ASSET_TAG_MODEL && asset->mdl.meshCount > 0) { model = &asset->mdl; } } u32 point_cnt = 0; u32 idx_cnt = 0; if (model && (add_hull || add_trimesh)) { for (int i = 0; i < model->meshCount; ++i) { Mesh *mesh = &model->meshes[i]; if (!mesh->vertices || mesh->vertexCount <= 0) { model = NULL; break; } point_cnt += (u32)mesh->vertexCount; idx_cnt += (u32)mesh->triangleCount * 3; } if (point_cnt < (add_trimesh ? 3u : 4u) || (add_trimesh && idx_cnt < 3)) { model = NULL; } if (!model) { TraceLog(LOG_WARNING, "model has no usable " "collision geometry"); } } if (add_box || add_sphere || add_capsule || add_cylinder || model) { edit.focus = 0; EditorAction action = action_begin(ED_ACTION_PHYSICS, false); entity_physics_clear(&ent->phys); PhysicsShape *shapes = realloc( body->shapes, (body->shape_cnt + 1) * sizeof(*shapes)); if (!shapes) { perror("physics shape allocation failed"); exit(1); } body->shapes = shapes; body->shapes[body->shape_cnt] = (PhysicsShape){ .tag = PHYS_SHAPE_BOX, .Box.sz = {1, 1, 1}, .rot = QuaternionIdentity(), .density = 1.0f, .friction = 0.5f, .category_bits = BIT(PHYS_MASK_DEFAULT), .mask_bits = UINT32_MAX, }; if (add_sphere) { PhysicsShape *shape = &body->shapes[body->shape_cnt]; shape->tag = PHYS_SHAPE_SPHERE; shape->Sphere.radius = 0.5f; } if (add_capsule) { PhysicsShape *shape = &body->shapes[body->shape_cnt]; shape->tag = PHYS_SHAPE_CAPSULE; shape->Capsule.radius = 0.5f; shape->Capsule.height = 1.0f; } if (add_cylinder) { PhysicsShape *shape = &body->shapes[body->shape_cnt]; shape->tag = PHYS_SHAPE_CYLINDER; shape->Cylinder.radius = 0.5f; shape->Cylinder.height = 1.0f; } if (model && fit_model) { Model local_model = *model; local_model.transform = MatrixIdentity(); BoundingBox bounds = GetModelBoundingBox(local_model); Vec3 center = Vector3Scale( Vector3Add( bounds.min, bounds.max), 0.5f); PhysicsShape *shape = &body->shapes[body->shape_cnt]; shape->Box.sz = Vector3Subtract(bounds.max, bounds.min); shape->rot = QuaternionInvert(body->rot); shape->pos = Vector3RotateByQuaternion( Vector3Subtract(center, body->pos), shape->rot); } if (model && (add_hull || add_trimesh)) { PhysicsShape *shape = &body->shapes[body->shape_cnt]; shape->tag = add_trimesh ? PHYS_SHAPE_STATIC_TRIMESH : PHYS_SHAPE_CONVEX_HULL; shape->rot = QuaternionInvert(body->rot); shape->pos = Vector3RotateByQuaternion( Vector3Negate(body->pos), shape->rot); physics_shape_model_geometry( shape, model, point_cnt, idx_cnt); } body->shape_cnt++; action_commit(&action); } GuiUnlock(); int mode = body->mode; if (GuiDropdownBox(type_box, "Static;Kinematic;Dynamic", &mode, type_open)) { type_open = !type_open; edit.focus = 0; } if (input && IsKeyPressed(KEY_ESCAPE)) { type_open = false; } if (mode != body->mode || sleep != body->enable_sleep || floats_changed || pos_changed || rot_changed) { EditorAction action = action_begin(ED_ACTION_PHYSICS, false); entity_physics_clear(&ent->phys); if (pos_changed) { body->pos = pos; } if (rot_changed) { body->rot = QuaternionFromEuler( rot.x * DEG2RAD, rot.y * DEG2RAD, rot.z * DEG2RAD); } body->mode = (PhysicsMode)mode; body->enable_sleep = sleep; body->gravity_scale = values[0]; body->linear_damping = values[1]; body->angular_damping = values[2]; action_commit(&action); } } else { GuiLabel(info, "Select a body"); } reset: GuiUnlock(); if (input) { ed.focus_captured = type_open || edit.focus != 0; } } static void panel_time(EditorPanelContainer container, bool input) { static PanelEdit edit = {0}; Rect bounds = ed.panel[container].bounds; edit.next_id = 1; edit.input = input; /* Escape cancels numeric entry before closing the panel. */ if (input && IsKeyPressed(KEY_ESCAPE)) { if (edit.focus != 0) { edit.focus = 0; return; } ed.panel[container].enabled = false; ed.focus = ED_PNL_NULL; ed.focus_captured = false; return; } if (input && edit.focus == 0 && IsKeyDown(KEY_LEFT_CONTROL) && IsKeyPressed(KEY_Z)) { if (IsKeyDown(KEY_LEFT_SHIFT)) { history_redo(); } else { history_undo(); } } GuiPanel(bounds, "Time of Day"); Rect row = { bounds.x + 16, bounds.y + 40, bounds.width - 32, 24 }; int minutes = (int)(ed.time_preview * 24.0f * 60.0f); char label[64]; snprintf(label, sizeof(label), "Preview time: %02i:%02i", minutes / 60, minutes % 60); GuiLabel(row, label); row.y += 32; f32 hours = ed.time_preview * 24.0f; if (GuiSlider(row, NULL, NULL, &hours, 0.0f, 24.0f)) { ed.time_preview = fmodf(hours / 24.0f, 1.0f); } row.y += 40; if (GuiButton(row, ed.time_preview_playing ? "Pause preview" : "Play preview")) { edit.focus = 0; ed.time_preview_playing = !ed.time_preview_playing; } row.y += 40; minutes = (int)(env.day_cur * 24.0f * 60.0f); snprintf(label, sizeof(label), "Scene starting time: %02i:%02i", minutes / 60, minutes % 60); GuiLabel(row, label); row.y += 28; if (GuiButton(row, "Use preview as scene starting time")) { edit.focus = 0; EditorAction action = action_begin(ED_ACTION_TIME, false); env.day_cur = ed.time_preview; action_commit(&action); } row.y += 40; GuiLabel(row, "Day length (real seconds)"); row.y += 28; f32 day_len = env.day_len; if (panel_float(&edit, row, &day_len, false)) { if (isfinite(day_len) && day_len > 0.0f) { EditorAction action = action_begin(ED_ACTION_TIME, false); env.day_len = day_len; action_commit(&action); } } } static EditorCommand panel_commands(Rect bounds, bool input) { static const cstr names[ED_CMD_CNT] = { [ED_CMD_TIME] = "Time of day", [ED_CMD_SPAWN_PREFAB] = "Spawn prefab", [ED_CMD_SPAWN_MODEL] = "Spawn model entity", }; GuiPanel(bounds, "Command Menu"); Rect search = { bounds.x + 8, bounds.y + 30, bounds.width - 16, 24 }; char previous[sizeof(command_menu.query)]; memcpy(previous, command_menu.query, sizeof(previous)); /* Keep keyboard focus in the search field. */ GuiTextBox(search, command_menu.query, sizeof(command_menu.query), input); if (strcmp(previous, command_menu.query) != 0) { command_menu.selected = 0; } struct CommandMatch { EditorCommand command; int score; } matches[ED_CMD_CNT]; int count = 0; for (int i = 0; i < ED_CMD_CNT; ++i) { int score = fuzzy_match(command_menu.query, names[i]); if (score == INT32_MIN) { continue; } int index = count; while (index > 0 && matches[index - 1].score < score) { matches[index] = matches[index - 1]; index--; } matches[index] = (struct CommandMatch){i, score}; count++; } Rect row = { search.x, search.y + 32, search.width, 24 }; if (!count) { GuiLabel(row, "No matching commands"); return ED_CMD_NONE; } if (input) { int step = 0; if (IsKeyPressed(KEY_UP)) { step = -1; } else if (IsKeyPressed(KEY_DOWN)) { step = 1; } else if (IsKeyPressed(KEY_TAB)) { bool backwards = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT); step = backwards ? -1 : 1; } command_menu.selected = (command_menu.selected + step + count) % count; } EditorCommand chosen = ED_CMD_NONE; for (int i = 0; i < count; ++i) { bool hovered = input && CheckCollisionPointRec(GetMousePosition(), row); if (hovered && IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { command_menu.selected = i; chosen = matches[i].command; } if (i == command_menu.selected) { DrawRectangleRec(row, GetColor(GuiGetStyle(LISTVIEW, BASE_COLOR_PRESSED))); } GuiLabel(row, names[matches[i].command]); row.y += row.height; } if (input && IsKeyPressed(KEY_ENTER)) { chosen = matches[command_menu.selected].command; } GuiLabel((Rect){ search.x, bounds.y + bounds.height - 30, search.width, 22 }, "Up/Down/Tab: select Enter: run Esc: close"); return chosen; } static void panel_draw(EditorPanelContainer container) { if (!ed.panel[container].enabled) { return; } bool input = ed.interact == ED_INTERACT_NONE && ed.focus == container && (!ed.panel[ED_PNL_CENTER].enabled || container == ED_PNL_CENTER); Rect bounds = ed.panel[container].bounds; int state = GuiGetState(); if (!input) { GuiSetState(STATE_DISABLED); } switch (ed.panel[container].type) { case ED_PNL_TYPE_SCENE: panel_scene(container, input); break; case ED_PNL_TYPE_PROPERTIES: panel_properties(container, input); break; case ED_PNL_TYPE_SPACE: bool exit = false; if (IsKeyPressed(KEY_ESCAPE)) { exit = true; } if (IsKeyPressed(KEY_Q)) { ed.panel[ED_PNL_LEFT].enabled = !ed.panel[ED_PNL_LEFT].enabled; exit = true; } if (IsKeyPressed(KEY_E)) { ed.panel[ED_PNL_RIGHT].enabled = !ed.panel[ED_PNL_RIGHT].enabled; exit = true; } if (IsKeyPressed(KEY_SLASH)) { memset(&command_menu, 0, sizeof(command_menu)); ed.panel[container].type = ED_PNL_TYPE_CMD; break; } if (exit) { ed.panel[container].enabled = false; ed.focus = ED_PNL_NULL; ed.focus_captured = false; } panel_space(container); break; case ED_PNL_TYPE_CMD: { if (input && IsKeyPressed(KEY_ESCAPE)) { ed.panel[container].enabled = false; ed.focus = ED_PNL_NULL; ed.focus_captured = false; break; } EditorCommand command = panel_commands(bounds, input); switch (command) { case ED_CMD_TIME: ed.panel[container].type = ED_PNL_TYPE_TIME; break; case ED_CMD_SPAWN_PREFAB: ed.panel[container].enabled = false; ed.focus = ED_PNL_NULL; ed.focus_captured = false; break; case ED_CMD_SPAWN_MODEL: asset_picker_open(ASSET_TAG_MODEL, ASSET_PICK_MDL_ENT_CREATE); break; default: break; } break; } case ED_PNL_TYPE_TIME: panel_time(container, input); break; case ED_PNL_TYPE_ASSET_PICKER: { cstr key = panel_asset_picker(bounds, input); if (!key) { break; } switch (asset_picker.mode) { case ASSET_PICK_MDL_COMP_ADD: case ASSET_PICK_MDL_COMP_UPDATE: if (editor_set_model(key, asset_picker.mode)) { asset_picker_close(); } break; case ASSET_PICK_MDL_ENT_CREATE: { Asset *asset = asset_get(key); EditorAction action = action_begin(ED_ACTION_CREATE, false); u64 id = entity_id_from_pid(action.item[0].pid); Entity *ent = entity_get(id); ent->tr.pos = main_cam.cam.target; ent->mdl = (ModelCmp){ .key = strdup(key), .mdl = &asset->mdl, .has = true, .draw = true, }; if (ent->name) { free(ent->name); } ent->name = strdup(GetFileNameWithoutExt(key)); ed.sel_cnt = 1; ed.sel_point = ent->tr.pos; ed.sel_rot = ent->tr.rot; action_commit(&action); asset_picker_close(); ed.interact = ED_INTERACT_MOVE; break; } case ASSET_PICK_MAT_TEX: editor_set_material(key, asset_picker.mat_idx, MAT_PROP_TEXTURE); asset_picker_close(); break; } break; } case ED_PNL_TYPE_PHYSICS_ELEMENTS: panel_physics_elements(container); break; case ED_PNL_TYPE_PHYSICS_PROPERTIES: panel_physics_properties(container, input); break; } GuiSetState(state); } void editor_draw_ui(void) { statusline(); /* L/R panel info */ f32 screen_w = GetScreenWidth(); f32 screen_h = GetScreenHeight(); f32 left_w = Clamp(screen_w * 0.16f, 210.0f, 280.0f); f32 right_w = Clamp(screen_w * 0.22f, 260.0f, 360.0f); f32 status_h = 25.0f; f32 margin = 8.0f; ed.panel[ED_PNL_LEFT].bounds = (Rect) { margin, margin, left_w, screen_h - status_h - margin * 2.0f }; ed.panel[ED_PNL_RIGHT].bounds = (Rect) { screen_w - right_w - margin, margin, right_w, screen_h - status_h - margin * 2.0f }; panel_draw(ED_PNL_LEFT); panel_draw(ED_PNL_RIGHT); if (ed.mode == ED_MODE_SCENE && !ed.panel[ED_PNL_RIGHT].enabled) { char stats[4][64]; snprintf(stats[0], sizeof(stats[0]), "%i FPS | %.2f ms", GetFPS(), app.frame_time_ms); snprintf(stats[1], sizeof(stats[1]), "Entities: %u", entity_count()); snprintf(stats[2], sizeof(stats[2]), "Selected: %i", ed.sel_cnt); snprintf(stats[3], sizeof(stats[3]), "Colliders: %s [C]", ed.draw_physics ? "on" : "off"); for (int i = 0; i < 4; ++i) { f32 width = editor_label_w(stats[i]); GuiLabel((Rect){ screen_w - margin - width, margin + i * 22.0f, width, 22.0f }, stats[i]); } } if (ed.interact == ED_INTERACT_SELECT) { if (started && sel_dist > 20) { sel_rec_draw(); } } if (ed.panel[ED_PNL_CENTER].enabled) { f32 width = fminf(640, screen_w - margin * 2); f32 height = fminf(420, screen_h - status_h - margin * 2); ed.panel[ED_PNL_CENTER].bounds = (Rect){ (screen_w - width) * 0.5f, (screen_h - status_h - height) * 0.5f, width, height }; DrawRectangle(0, 0, GetScreenWidth(), GetScreenHeight(), Fade(BLACK, 0.35f)); panel_draw(ED_PNL_CENTER); } } /* 3D gizmos */ static void gizmo_transform(void) { Vec3 point = ed.sel_point; Quaternion rot = ed.sel_rot; Vec3 right = Vector3RotateByQuaternion((Vec3){1, 0, 0}, rot); Vec3 up = Vector3RotateByQuaternion((Vec3){0, 1, 0}, rot); Vec3 fwd = Vector3RotateByQuaternion((Vec3){0, 0, 1}, rot); f32 len = 0.75f; right = Vector3Scale(right, len); up = Vector3Scale(up, len); fwd = Vector3Scale(fwd, len); DrawLine3D(point, Vector3Add(point, fwd), BLUE); DrawLine3D(point, Vector3Add(point, up), GREEN); DrawLine3D(point, Vector3Add(point, right), RED); } static void gizmo_move(void) { Vec3 pivot = ed.sel_point; Vec3 axes[3] = { {1, 0, 0}, {0, 1, 0}, {0, 0, 1}, }; Color colors[3] = {RED, GREEN, BLUE}; int axis = ed.move_axis >= 0 ? ed.move_axis : 1; bool plane = ed.move_axis < 0 || ed.move_plane; bool local = main_cam.local_space && ed.sel_cnt == 1 && ed.move_axis >= 0; if (IsKeyDown(KEY_LEFT_ALT)) { axis = 1; plane = false; local = false; } if (local) { for (int i = 0; i < 3; ++i) { axes[i] = Vector3RotateByQuaternion( axes[i], ed.move_rotation); } } for (int i = 0; i < 3; ++i) { bool enabled = plane ? i != axis : i == axis; if (!enabled) { continue; } Vec3 extent = Vector3Scale(axes[i], 100.0f); DrawLine3D( Vector3Subtract(pivot, extent), Vector3Add(pivot, extent), Fade(colors[i], 0.7f)); } if (!IsKeyDown(KEY_LEFT_CONTROL) || ed.grid_sz <= 0) { return; } f32 step = ed.grid_sz; Vec3 origin = local ? ed.move_snap_origin : Vector3Zero(); Vec3 center = pivot; /* Align the grid center along the permitted movement axes. */ for (int i = 0; i < 3; ++i) { bool enabled = plane ? i != axis : i == axis; if (!enabled) { continue; } f32 coordinate = Vector3DotProduct( Vector3Subtract(pivot, origin), axes[i]); f32 snapped = roundf(coordinate / step) * step; center = Vector3Add(center, Vector3Scale(axes[i], snapped - coordinate)); } const int count = 10; if (plane) { Vec3 u = axes[(axis + 1) % 3]; Vec3 v = axes[(axis + 2) % 3]; Vec3 extent_u = Vector3Scale(u, count * step); Vec3 extent_v = Vector3Scale(v, count * step); Color color = Fade(LIGHTGRAY, 0.5f); for (int i = -count; i <= count; ++i) { Vec3 along_u = Vector3Add( center, Vector3Scale(u, i * step)); Vec3 along_v = Vector3Add( center, Vector3Scale(v, i * step)); DrawLine3D( Vector3Subtract(along_u, extent_v), Vector3Add(along_u, extent_v), color); DrawLine3D( Vector3Subtract(along_v, extent_u), Vector3Add(along_v, extent_u), color); } } else { /* Orient ticks across the axis, facing the camera. */ Vec3 forward = Vector3Normalize(Vector3Subtract( main_cam.cam.target, main_cam.cam.position)); Vec3 tick = Vector3CrossProduct(axes[axis], forward); if (Vector3LengthSqr(tick) < 0.000001f) { tick = axes[(axis + 1) % 3]; } tick = Vector3Scale(Vector3Normalize(tick), step * 0.15f); for (int i = -count; i <= count; ++i) { Vec3 point = Vector3Add( center, Vector3Scale(axes[axis], i * step)); Color color = i == 0 ? WHITE : Fade(colors[axis], 0.7f); DrawLine3D( Vector3Subtract(point, tick), Vector3Add(point, tick), color); } } } static void gizmo_rotate(void) { Vec3 axes[3] = { {1, 0, 0}, {0, 1, 0}, {0, 0, 1}, }; Color colors[3] = {RED, GREEN, BLUE}; int axis_index = ed.rotate_axis < 0 ? 1 : ed.rotate_axis; bool local = main_cam.local_space && ed.sel_cnt == 1; for (u32 i = 0; i < rot_record_len; ++i) { if (ed.rotate_group && i > 0) { break; } u64 id = entity_id_from_pid(tr_action.item[i].pid); Vec3 pivot = ed.rotate_group ? ed.rotate_pivot : entity_world_pos(id); Quaternion original = rot_record[i].rot; Quaternion current = entity_world_rot(id); if (ed.rotate_group && ed.sel_cnt > 1) { current = QuaternionNormalize(QuaternionMultiply( current, QuaternionInvert(original))); original = QuaternionIdentity(); } Vec3 axis = axes[axis_index]; if (local) { axis = Vector3RotateByQuaternion(axis, original); } Vec3 reference = axes[(axis_index + 1) % 3]; Vec3 model_axis = reference; Vec3 start = Vector3RotateByQuaternion(model_axis, original); start = Vector3Subtract(start, Vector3Scale(axis, Vector3DotProduct(start, axis))); if (Vector3LengthSqr(start) < 0.0001f) { if (ed.rotate_axis < 0) { continue; } model_axis = axes[(axis_index + 2) % 3]; start = Vector3RotateByQuaternion(model_axis, original); start = Vector3Subtract(start, Vector3Scale(axis, Vector3DotProduct(start, axis))); } start = Vector3Normalize(start); Vec3 applied = Vector3RotateByQuaternion(model_axis, current); applied = Vector3Normalize(Vector3Subtract(applied, Vector3Scale(axis, Vector3DotProduct(applied, axis)))); Vec3 u = local ? start : reference; Vec3 v = Vector3Normalize(Vector3CrossProduct(axis, u)); /* Keep the ring roughly the same size on screen. */ f32 view_height = main_cam.cam.fovy; if (main_cam.cam.projection == CAMERA_PERSPECTIVE) { Vec3 forward = Vector3Normalize(Vector3Subtract( main_cam.cam.target, main_cam.cam.position)); f32 depth = Vector3DotProduct( Vector3Subtract(pivot, main_cam.cam.position), forward); if (depth <= 0.0f) { continue; } view_height = 2.0f * depth * tanf(main_cam.cam.fovy * DEG2RAD * 0.5f); } f32 radius = view_height * 65.0f / GetScreenHeight(); if (ed.rotate_group) { DrawSphereEx(pivot, radius * 0.04f, 4, 6, WHITE); } Color color = Fade(colors[axis_index], 0.8f); Vec3 previous = Vector3Add(pivot, Vector3Scale(u, radius)); for (int j = 1; j <= 64; ++j) { f32 angle = 2.0f * PI * j / 64.0f; Vec3 direction = Vector3Add( Vector3Scale(u, cosf(angle)), Vector3Scale(v, sinf(angle))); Vec3 point = Vector3Add( pivot, Vector3Scale(direction, radius)); DrawLine3D(previous, point, color); previous = point; } if (IsKeyDown(KEY_LEFT_CONTROL)) { f32 degrees = rotation_steps[ed.rotation_snap]; /* At one-degree snapping, show every fifth tick. */ int stride = degrees < 5.0f ? 5 : 1; int count = (int)roundf(360.0f / degrees); for (int j = 0; j < count; j += stride) { f32 angle = j * degrees * DEG2RAD; Vec3 direction = Vector3Add( Vector3Scale(u, cosf(angle)), Vector3Scale(v, sinf(angle))); DrawLine3D( Vector3Add(pivot, Vector3Scale(direction, radius * 0.94f)), Vector3Add(pivot, Vector3Scale(direction, radius * 1.06f)), Fade(LIGHTGRAY, 0.8f)); } } /* Zero reference, applied angle, and rotation axis. */ DrawLine3D(pivot, Vector3Add(pivot, Vector3Scale(u, radius)), Fade(LIGHTGRAY, 0.4f)); DrawLine3D(pivot, Vector3Add(pivot, Vector3Scale(applied, radius)), WHITE); Vec3 extent = Vector3Scale(axis, radius * 1.3f); DrawLine3D( Vector3Subtract(pivot, extent), Vector3Add(pivot, extent), color); } } static void gizmo_scale(void) { Vec3 axes[3] = { {1, 0, 0}, {0, 1, 0}, {0, 0, 1}, }; Color colors[3] = {RED, GREEN, BLUE}; for (u32 i = 0; i < tr_action.item_cnt; ++i) { u64 id = entity_id_from_pid(tr_action.item[i].pid); Vec3 position = entity_world_pos(id); Vec3 pivot = ed.scale_group ? ed.scale_pivot : position; f32 view_height = main_cam.cam.fovy; if (main_cam.cam.projection == CAMERA_PERSPECTIVE) { Vec3 forward = Vector3Normalize(Vector3Subtract( main_cam.cam.target, main_cam.cam.position)); f32 depth = Vector3DotProduct( Vector3Subtract(pivot, main_cam.cam.position), forward); if (depth <= 0.0f) { continue; } view_height = 2.0f * depth * tanf(main_cam.cam.fovy * DEG2RAD * 0.5f); } f32 pixel = view_height / GetScreenHeight(); if (!ed.scale_group || i == 0) { DrawSphereEx(pivot, pixel * 3.0f, 4, 6, WHITE); } if (ed.scale_group) { DrawLine3D(pivot, position, Fade(LIGHTGRAY, 0.6f)); } if (ed.scale_axis < 0) { continue; } Matrix world = entity_world_matrix(id); Vec3 endpoint = Vector3Transform(axes[ed.scale_axis], world); Vec3 direction = Vector3Subtract(endpoint, position); if (Vector3LengthSqr(direction) == 0.0f) { continue; } direction = Vector3Normalize(direction); Vec3 extent = Vector3Scale(direction, 100.0f); DrawLine3D( Vector3Subtract(pivot, extent), Vector3Add(pivot, extent), Fade(colors[ed.scale_axis], 0.8f)); } } static void physics_draw_hull(b3ShapeId shape_id, Color colour) { if (!b3Shape_IsValid(shape_id)) { return; } const b3HullData *hull = b3Shape_GetHull(shape_id); const b3Vec3 *points = b3GetHullPoints(hull); const b3HullHalfEdge *edges = b3GetHullEdges(hull); b3BodyId body_id = b3Shape_GetBody(shape_id); for (int i = 0; i < hull->edgeCount; ++i) { const b3HullHalfEdge *edge = &edges[i]; /* Each edge has two halves; draw it once. */ if (i > edge->twin) { continue; } b3Pos a = b3Body_GetWorldPoint( body_id, points[edge->origin]); b3Pos b = b3Body_GetWorldPoint( body_id, points[edges[edge->twin].origin]); DrawLine3D( (Vec3){a.x, a.y, a.z}, (Vec3){b.x, b.y, b.z}, colour); } } static void physics_draw_shapes(u64 id) { Entity *ent = entity_get(id); if (!ent) { return; } Matrix entity_world = entity_world_matrix(id); Quaternion entity_rot = entity_world_rot(id); for (u32 i = 0; i < ent->phys.body_cnt; ++i) { PhysicsBody *body = &ent->phys.bodies[i]; Matrix body_local = MatrixMultiply( QuaternionToMatrix(body->rot), MatrixTranslate( body->pos.x, body->pos.y, body->pos.z)); Matrix body_world = MatrixMultiply(body_local, entity_world); for (u32 j = 0; j < body->shape_cnt; ++j) { PhysicsShape *shape = &body->shapes[j]; bool selected = ent->ed.selected; if (ed.mode == ED_MODE_PHYSICS) { selected = ent->pid == ed.physics_pid && ed.physics_body == (int)i && (ed.physics_shape < 0 || ed.physics_shape == (int)j); } Color colour = selected ? ORANGE : SKYBLUE; Matrix shape_local = MatrixMultiply( QuaternionToMatrix(shape->rot), MatrixTranslate( shape->pos.x, shape->pos.y, shape->pos.z)); Matrix world = MatrixMultiply(shape_local, body_world); Vec3 scale = { Vector3Length( (Vec3){world.m0, world.m1, world.m2}), Vector3Length( (Vec3){world.m4, world.m5, world.m6}), Vector3Length( (Vec3){world.m8, world.m9, world.m10}), }; Vec3 center = {world.m12, world.m13, world.m14}; Quaternion rotation = QuaternionMultiply( QuaternionMultiply(entity_rot, body->rot), shape->rot); Matrix transform = MatrixMultiply( QuaternionToMatrix(rotation), MatrixTranslate(center.x, center.y, center.z)); if (shape->tag == PHYS_SHAPE_CONVEX_HULL) { PhysicsBodyRuntime *bodies = ent->phys.runtime.bodies; if (bodies && bodies[i].shapes) { physics_draw_hull(bodies[i].shapes[j], colour); } continue; } if (shape->tag == PHYS_SHAPE_STATIC_TRIMESH) { const Vec3 *points = shape->TriMesh.points; const u32 *indices = shape->TriMesh.indices; u32 point_cnt = shape->TriMesh.point_cnt; if (!points || !indices) { continue; } for (u32 k = 0; k + 2 < shape->TriMesh.idx_cnt; k += 3) { u32 ia = indices[k]; u32 ib = indices[k + 1]; u32 ic = indices[k + 2]; if (ia >= point_cnt || ib >= point_cnt || ic >= point_cnt) { continue; } Vec3 a = Vector3Transform(points[ia], world); Vec3 b = Vector3Transform(points[ib], world); Vec3 c = Vector3Transform(points[ic], world); DrawLine3D(a, b, colour); DrawLine3D(b, c, colour); DrawLine3D(c, a, colour); } continue; } rlPushMatrix(); rlMultMatrixf(MatrixToFloat(transform)); switch (shape->tag) { case PHYS_SHAPE_BOX: { Vec3 size = Vector3Multiply( shape->Box.sz, scale); DrawCubeWires((Vec3){0}, size.x, size.y, size.z, colour); break; } case PHYS_SHAPE_SPHERE: { Vec3 entity_scale = { Vector3Length((Vec3){ entity_world.m0, entity_world.m1, entity_world.m2, }), Vector3Length((Vec3){ entity_world.m4, entity_world.m5, entity_world.m6, }), Vector3Length((Vec3){ entity_world.m8, entity_world.m9, entity_world.m10, }), }; f32 largest = fmaxf(entity_scale.x, fmaxf(entity_scale.y, entity_scale.z)); DrawSphereWires((Vec3){0}, shape->Sphere.radius * largest, 8, 12, colour); break; } case PHYS_SHAPE_CAPSULE: { f32 radius = shape->Capsule.radius * fmaxf(scale.x, scale.z); f32 half_height = shape->Capsule.height * scale.y * 0.5; if (half_height == 0) { DrawSphereWires((Vec3){0}, radius, 8, 12, colour); } else { DrawCapsuleWires( (Vec3) {0,-half_height,0}, (Vec3){0,half_height,0}, radius, 4, 8, colour); } break; } case PHYS_SHAPE_CYLINDER: { f32 radius = shape->Cylinder.radius * fmaxf(scale.x, scale.z); f32 half_height = shape->Cylinder.height * scale.y * 0.5f; DrawCylinderWiresEx( (Vec3){0, -half_height, 0}, (Vec3){0, half_height, 0}, radius, radius, 12, colour); break; } default: break; } rlPopMatrix(); } } } void editor_draw_3d(void) { if (ed.mode == ED_MODE_PHYSICS) { physics_draw_shapes(entity_id_from_pid(ed.physics_pid)); return; } if (ed.mode != ED_MODE_SCENE) { return; } if (ed.draw_physics) { for (u32 i = 0; i < entity_count(); ++i) { u64 id = entity_id_at(i); Entity *ent = entity_get(id); if (ent->phys.has) { physics_draw_shapes(id); } } } if (ed.interact == ED_INTERACT_MOVE) { gizmo_move(); } if (ed.interact == ED_INTERACT_ROTATE) { gizmo_rotate(); } if (ed.interact == ED_INTERACT_SCALE) { gizmo_scale(); } if (ed.sel_cnt > 0 && ed.interact != ED_INTERACT_MOVE && ed.interact != ED_INTERACT_SCALE) { gizmo_transform(); } } /* utils */ f32 editor_label_w(cstr text) { return (f32)GuiGetTextWidth(text) + 2.0f * GuiGetStyle(LABEL, BORDER_WIDTH) + 2.0f * GuiGetStyle(LABEL, TEXT_PADDING) + 2.0f; } void editor_rename_selected(cstr name) { EditorAction action = action_begin(ED_ACTION_RENAME, false); for (u32 i = 0; i < action.item_cnt; ++i) { u64 id = entity_id_from_pid(action.item[i].pid); Entity *ent = entity_get(id); assert(ent); char *new_name = strdup(name); if (!new_name) { perror("rename alloc failed."); exit(1); } action.item[i].before.rename.name = ent->name; ent->name = new_name; } action_commit(&action); }