From d465378f7bed01855ae45e228141725e102426ec Mon Sep 17 00:00:00 2001 From: Einar Andersson <72999+drdator@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:17:19 +0200 Subject: [PATCH] Improve editor selection and tool workflows --- src/brush-primitive-icons.ts | 2 +- src/diagnostics-dialog.ts | 12 +- src/editor-document.ts | 42 +++++++ src/editor-queries.ts | 8 ++ src/editor-selection.ts | 43 ++++++- src/editor-transforms.ts | 34 +++++- src/editor.ts | 20 ++++ src/geometry-primitives.ts | 105 +++++++++++++++++ src/gizmo.ts | 63 +++++------ src/gl-utils.ts | 38 +++++++ src/main.ts | 16 ++- src/preferences-dialog.ts | 8 +- src/project-config.ts | 22 ++++ .../2026-08-04-project-settings.md | 15 +++ .../2026-08-05-editor-workflow.md | 21 ++++ src/style.css | 14 +++ src/ui-toolbar.ts | 46 ++++++-- src/ui.ts | 1 - src/vertex.ts | 61 +++++++++- src/viewport2d-interaction.ts | 59 +++++++--- src/viewport2d.ts | 1 + src/viewport3d-geometry.ts | 28 +++++ src/viewport3d-render.ts | 21 +++- src/viewport3d-selection.ts | 61 +++++++++- src/viewport3d.ts | 29 ++++- tests/editor-document.test.ts | 43 +++++++ tests/face-editing.test.ts | 16 +++ tests/gizmo-geometry.test.ts | 22 ++++ tests/project-config.test.ts | 25 +++++ tests/selection-texture-sync.test.ts | 64 +++++++++++ tests/ui-toolbar.test.ts | 10 ++ tests/viewport2d-interaction.test.ts | 68 ++++++++++- tests/viewport3d-edge-selection.test.ts | 106 ++++++++++++++++++ 33 files changed, 1032 insertions(+), 92 deletions(-) create mode 100644 src/geometry-primitives.ts create mode 100644 src/release-notes/2026-08-04-project-settings.md create mode 100644 src/release-notes/2026-08-05-editor-workflow.md create mode 100644 tests/gizmo-geometry.test.ts create mode 100644 tests/selection-texture-sync.test.ts create mode 100644 tests/ui-toolbar.test.ts create mode 100644 tests/viewport3d-edge-selection.test.ts diff --git a/src/brush-primitive-icons.ts b/src/brush-primitive-icons.ts index 68ec3c7..6c6d2a4 100644 --- a/src/brush-primitive-icons.ts +++ b/src/brush-primitive-icons.ts @@ -18,7 +18,7 @@ export function brushPrimitiveToolbarIconMarkup(primitive: BrushPrimitive): stri } export function brushPrimitiveToolbarTitle(primitive: BrushPrimitive): string { - return `Create ${primitive} brush (2)`; + return `Create ${primitive} brush (2) · Click again for brush options`; } export function applyBrushPrimitiveToolbarIcon(button: HTMLElement | null, primitive: BrushPrimitive): void { diff --git a/src/diagnostics-dialog.ts b/src/diagnostics-dialog.ts index 9f2e2c5..b3e86d6 100644 --- a/src/diagnostics-dialog.ts +++ b/src/diagnostics-dialog.ts @@ -12,7 +12,6 @@ import type { Editor } from './editor'; import { createDesignReviewWorkspace } from './design-review-workspace'; import { createEntityRelationshipWorkspace } from './entity-relationship-workspace'; import { createPerformanceWorkspace } from './performance-workspace'; -import { saveProjectConfiguration } from './project-config'; export type DiagnosticsTab = 'map' | 'design-review' | 'entity-logic' | 'performance' | 'entities' | 'find' | 'brush-macros'; @@ -135,8 +134,9 @@ export function openDiagnosticsDialog(editor: Editor, initialTab: DiagnosticsTab for (const diagnostic of diagnostics.slice(0, 500)) { list.appendChild(diagnosticRow(editor, diagnostic, selectedFixes, updateFixStatus, () => { if (!editor.projectConfiguration.diagnostics.mutedCodes.includes(diagnostic.code)) { - editor.projectConfiguration.diagnostics.mutedCodes.push(diagnostic.code); - saveProjectConfiguration(editor.projectConfiguration); + const project = structuredClone(editor.projectConfiguration); + project.diagnostics.mutedCodes.push(diagnostic.code); + editor.updateProjectConfiguration(project, { label: `Mute ${diagnostic.code} diagnostics`, notify: false }); } render(); })); @@ -154,9 +154,9 @@ export function openDiagnosticsDialog(editor: Editor, initialTab: DiagnosticsTab const row = document.createElement('div'); row.className = 'diagnostic-muted-row'; row.append(code, button('Unmute', () => { - editor.projectConfiguration.diagnostics.mutedCodes = - editor.projectConfiguration.diagnostics.mutedCodes.filter(item => item !== code); - saveProjectConfiguration(editor.projectConfiguration); + const project = structuredClone(editor.projectConfiguration); + project.diagnostics.mutedCodes = project.diagnostics.mutedCodes.filter(item => item !== code); + editor.updateProjectConfiguration(project, { label: `Unmute ${code} diagnostics`, notify: false }); render(); })); muted.append(row); diff --git a/src/editor-document.ts b/src/editor-document.ts index 563e66e..5bbfd0f 100644 --- a/src/editor-document.ts +++ b/src/editor-document.ts @@ -1,6 +1,14 @@ import { createBoxBrush } from './brush'; import { createEntity, createWorldspawn } from './entity'; import { parseMapWithDiagnostics, serializeMap as serializeEntities } from './mapfile'; +import { + normalizeProjectConfiguration, + loadProjectConfiguration, + readMapProjectConfiguration, + saveProjectConfiguration, + writeMapProjectConfiguration, + type ProjectConfiguration, +} from './project-config'; import type { Editor } from './editor'; import { commitTransaction, @@ -30,6 +38,7 @@ export interface DocumentHistoryAuxiliary { unsupportedMapConstructs: Editor['unsupportedMapConstructs']; savedDocumentRevision: number; documentSessionStartedAt: number; + projectConfiguration: ProjectConfiguration; } export function captureDocumentHistoryAuxiliary(editor: Editor): DocumentHistoryAuxiliary { @@ -40,6 +49,7 @@ export function captureDocumentHistoryAuxiliary(editor: Editor): DocumentHistory unsupportedMapConstructs: structuredClone(editor.unsupportedMapConstructs), savedDocumentRevision: editor.savedDocumentRevision, documentSessionStartedAt: editor.documentSessionStartedAt, + projectConfiguration: structuredClone(editor.projectConfiguration), }; } @@ -52,6 +62,10 @@ function restoreDocumentHistoryAuxiliary(editor: Editor, value: unknown): void { editor.unsupportedMapConstructs = structuredClone(auxiliary.unsupportedMapConstructs); editor.savedDocumentRevision = auxiliary.savedDocumentRevision; editor.documentSessionStartedAt = auxiliary.documentSessionStartedAt; + if (auxiliary.projectConfiguration) { + editor.applyPreferences(editor.preferences, auxiliary.projectConfiguration); + editor.notifyProjectConfigurationChanged(); + } } const pendingMapLoads = new WeakMap(); @@ -159,6 +173,8 @@ function applyParsedMap(editor: Editor, text: string, result: ParsedMapResult, f editor.entities = result.document.entities.length > 0 ? result.document.entities : [createWorldspawn()]; }, { auxiliary, assumeChanged: true }); if (fileName) editor.fileName = fileName; + const project = readMapProjectConfiguration(editor.entities) ?? loadProjectConfiguration(); + editor.applyPreferences(editor.preferences, project); editor.mapDiagnostics = result.diagnostics; editor.unsupportedMapConstructs = result.unsupportedConstructs; editor.selection = []; @@ -174,6 +190,7 @@ function applyParsedMap(editor: Editor, text: string, result: ParsedMapResult, f hasComments: /(^|\n)\s*\/\//.test(text), }; editor.beginDocumentSession(); + editor.notifyProjectConfigurationChanged(); editor.activityHistory.record({ source: 'file', status: result.diagnostics.some(diagnostic => diagnostic.severity === 'error') ? 'error' : 'success', @@ -237,6 +254,8 @@ export function restoreRecoveredMap( invalidatePendingMapLoad(editor); const result = parseMapWithDiagnostics(text); editor.entities = result.document.entities.length > 0 ? result.document.entities : [createWorldspawn()]; + const project = readMapProjectConfiguration(editor.entities) ?? loadProjectConfiguration(); + editor.applyPreferences(editor.preferences, project); editor.mapDiagnostics = result.diagnostics; editor.unsupportedMapConstructs = result.unsupportedConstructs; editor.originalMapSource = originalMapSource ? structuredClone(originalMapSource) : null; @@ -246,6 +265,7 @@ export function restoreRecoveredMap( editor.clearPointfile(false); resetEditorStateAfterDocumentReplacement(editor); editor.restoreDocumentState(documentRevision, savedDocumentRevision, documentSessionStartedAt); + editor.notifyProjectConfigurationChanged(); editor.statusMessage = editor.hasUnsavedChanges ? `Recovered unsaved changes to ${fileName}` : `Restored ${fileName}`; @@ -256,6 +276,7 @@ export function newMap(editor: Editor): void { const auxiliary = captureDocumentHistoryAuxiliary(editor); editor.transact('New map', () => { editor.entities = [createWorldspawn()]; + writeMapProjectConfiguration(editor.entities, editor.projectConfiguration); }, { auxiliary, assumeChanged: true }); editor.mapDiagnostics = []; editor.unsupportedMapConstructs = []; @@ -351,6 +372,7 @@ export function createDefaultMap(editor: Editor): void { // Startup/default-map initialization is deliberately non-undoable. The New // command establishes its undo entry before invoking this initializer. editor.entities = [createWorldspawn()]; + writeMapProjectConfiguration(editor.entities, editor.projectConfiguration); editor.mapDiagnostics = []; editor.unsupportedMapConstructs = []; editor.originalMapSource = null; @@ -381,3 +403,23 @@ export function createDefaultMap(editor: Editor): void { editor.redrawRequested = true; editor.statusMessage = 'Default map created'; } + +export interface UpdateProjectConfigurationOptions { + label?: string; + notify?: boolean; +} + +export function updateProjectConfiguration( + editor: Editor, + project: ProjectConfiguration, + options: UpdateProjectConfigurationOptions = {}, +): void { + const normalized = normalizeProjectConfiguration(project); + const auxiliary = captureDocumentHistoryAuxiliary(editor); + editor.transact(options.label ?? 'Update project settings', () => { + writeMapProjectConfiguration(editor.entities, normalized); + }, { auxiliary }); + editor.applyPreferences(editor.preferences, normalized); + saveProjectConfiguration(normalized); + if (options.notify !== false) editor.notifyProjectConfigurationChanged(); +} diff --git a/src/editor-queries.ts b/src/editor-queries.ts index 2a1f65f..3d13130 100644 --- a/src/editor-queries.ts +++ b/src/editor-queries.ts @@ -162,6 +162,14 @@ export function selectionBounds(editor: Editor): { mins: Vec3; maxs: Vec3 } | nu continue; } + if (item.type === 'face') { + for (const point of item.face.polygon) { + mins = vec3Min(mins, point); + maxs = vec3Max(maxs, point); + } + continue; + } + mins = vec3Min(mins, item.brush.mins); maxs = vec3Max(maxs, item.brush.maxs); } diff --git a/src/editor-selection.ts b/src/editor-selection.ts index 391889c..7685add 100644 --- a/src/editor-selection.ts +++ b/src/editor-selection.ts @@ -5,6 +5,43 @@ import type { Editor, SelectionItem } from './editor'; import { entityBounds as getEntityBounds, nonWorldspawnEntities } from './editor-queries'; import { isObjectInLockedGroup } from './named-groups'; +function canonicalTextureName(texture: string): string { + return texture.trim().replace(/\\/g, '/').replace(/^textures\//i, ''); +} + +function commonBrushTexture(brush: Brush): string | null { + const first = canonicalTextureName(brush.faces[0]?.texture ?? ''); + if (!first) return null; + const normalized = first.toLowerCase(); + return brush.faces.every(face => canonicalTextureName(face.texture).toLowerCase() === normalized) + ? first + : null; +} + +function selectionTexture(editor: Editor): string | null { + let common: string | null = null; + for (const item of editor.selection) { + const texture = item.type === 'face' + ? canonicalTextureName(item.face.texture) + : item.type === 'patch' + ? canonicalTextureName(item.patch.texture) + : item.type === 'brush' + ? commonBrushTexture(item.brush) + : null; + if (!texture) return null; + if (common === null) common = texture; + else if (common.toLowerCase() !== texture.toLowerCase()) return null; + } + return common; +} + +function adoptSelectionTexture(editor: Editor): void { + const texture = selectionTexture(editor); + if (!texture) return; + editor.currentTexture = texture; + editor.onLocateTexture?.(texture); +} + function selectsWholeEntity(editor: Editor, entity: Entity): boolean { return entity !== editor.worldspawn && (entity.brushes.length > 0 || entity.patches.length > 0); } @@ -59,6 +96,7 @@ export function selectBrushDirect(editor: Editor, entity: Entity, brush: Brush, return; } editor.selection.push({ type: 'brush', entity, brush }); + adoptSelectionTexture(editor); editor.redrawRequested = true; } @@ -127,6 +165,7 @@ export function selectPatch(editor: Editor, entity: Entity, patch: Patch, additi if (selected.has(groupedPatch)) continue; editor.selection.push({ type: 'patch', entity, patch: groupedPatch }); } + adoptSelectionTexture(editor); } editor.redrawRequested = true; return; @@ -145,6 +184,7 @@ export function selectPatchDirect(editor: Editor, entity: Entity, patch: Patch, return; } editor.selection.push({ type: 'patch', entity, patch }); + adoptSelectionTexture(editor); editor.redrawRequested = true; } @@ -203,6 +243,7 @@ export function selectFace( } else { editor.selection = [{ type: 'face', entity, brush, face }]; } + adoptSelectionTexture(editor); editor.redrawRequested = true; } @@ -222,7 +263,7 @@ export function getSelectedFace(editor: Editor): BrushFace | null { } function normalizedTextureName(texture: string): string { - return texture.trim().replace(/\\/g, '/').replace(/^textures\//i, '').toLowerCase(); + return canonicalTextureName(texture).toLowerCase(); } export function selectFacesByTexture(editor: Editor, texture = editor.currentTexture): void { diff --git a/src/editor-transforms.ts b/src/editor-transforms.ts index 369bc61..e8638f7 100644 --- a/src/editor-transforms.ts +++ b/src/editor-transforms.ts @@ -24,9 +24,17 @@ import { clonePatch, mirrorPatch, PatchControlPoint, rotatePatch, scalePatchCont import { entityBounds } from './editor-queries'; import type { Editor, SelectionItem } from './editor'; import { getSelectedBrushItems, getSelectedPatchItems } from './editor-selection'; -import { mirrorBrushLocked, rotateBrushLocked, scaleBrushLocked, translateBrushLocked } from './texture-lock'; +import { + captureBrushPrimitiveVertexTextureState, + mirrorBrushLocked, + restoreBrushPrimitiveVertexTextureState, + rotateBrushLocked, + scaleBrushLocked, + translateBrushLocked, +} from './texture-lock'; import { getEntityClassRegistry } from './entity-definitions'; import { cloneTransformDescriptor } from './transform-descriptor'; +import { collectBrushVertices, moveVertices } from './vertex'; export interface BrushScaleOriginal { brush: Brush; @@ -342,6 +350,30 @@ export function moveSelection(editor: Editor, delta: Vec3): void { }, { coalesceKey: 'move-selection', assumeChanged: true }); } +export function moveSelectedFaces(editor: Editor, delta: Vec3): void { + const selectedFaces = editor.selection.filter(item => item.type === 'face'); + if (selectedFaces.length === 0 || (delta[0] === 0 && delta[1] === 0 && delta[2] === 0)) return; + editor.transact('Move brush faces', () => { + const facesByBrush = new Map>(); + for (const item of selectedFaces) { + const faces = facesByBrush.get(item.brush) ?? new Set(); + faces.add(item.face); + facesByBrush.set(item.brush, faces); + } + for (const [brush, faces] of facesByBrush) { + const faceIndices = new Set([...faces].map(face => brush.faces.indexOf(face)).filter(index => index >= 0)); + const vertices = collectBrushVertices(brush); + const selectedIndices = vertices + .map((vertex, index) => vertex.faceIndices.some(faceIndex => faceIndices.has(faceIndex)) ? index : -1) + .filter(index => index >= 0); + const textureState = editor.textureLock ? captureBrushPrimitiveVertexTextureState(brush) : null; + moveVertices(brush, vertices, selectedIndices, delta); + if (textureState) restoreBrushPrimitiveVertexTextureState(textureState); + } + editor.redrawRequested = true; + }, { coalesceKey: 'move-brush-faces' }); +} + export function rotateSelection(editor: Editor, angleDeg: number): void { if (editor.selection.length === 0) return; const angle = (angleDeg / 180) * Math.PI; diff --git a/src/editor.ts b/src/editor.ts index e97e9c0..2fcd227 100644 --- a/src/editor.ts +++ b/src/editor.ts @@ -74,7 +74,9 @@ import { serializeMap as serializeEditorMap, serializeCompileMap as serializeEditorCompileMap, undo as undoDocument, + updateProjectConfiguration as updateEditorProjectConfiguration, type OriginalMapSource, + type UpdateProjectConfigurationOptions, } from './editor-document'; import { copySelection as copyEditorSelection, @@ -403,6 +405,7 @@ export class Editor { private documentChangeListeners = new Set<(change: EditorDocumentChange) => void>(); private documentStateChangeListeners = new Set<() => void>(); private documentSessionListeners = new Set<(startedAt: number) => void>(); + private projectConfigurationListeners = new Set<(project: ProjectConfiguration) => void>(); // Drag state for brush creation creating = false; createStart: Vec3 = [0, 0, 0]; @@ -1017,6 +1020,16 @@ export class Editor { return () => this.documentSessionListeners.delete(listener); } + subscribeProjectConfigurationChanges(listener: (project: ProjectConfiguration) => void): () => void { + this.projectConfigurationListeners.add(listener); + return () => this.projectConfigurationListeners.delete(listener); + } + + notifyProjectConfigurationChanged(): void { + const project = structuredClone(this.projectConfiguration); + for (const listener of this.projectConfigurationListeners) listener(project); + } + notifyDocumentChanged(label: string, previousRevision: number | null = null): void { const change = { label, previousRevision, revision: this.documentRevision }; if (!label.startsWith('MCP:')) { @@ -1113,6 +1126,13 @@ export class Editor { loadEditorMap(this, text); } + updateProjectConfiguration( + project: ProjectConfiguration, + options: UpdateProjectConfigurationOptions = {}, + ): void { + updateEditorProjectConfiguration(this, project, options); + } + restoreRecoveredMap( text: string, fileName: string, diff --git a/src/geometry-primitives.ts b/src/geometry-primitives.ts new file mode 100644 index 0000000..3812700 --- /dev/null +++ b/src/geometry-primitives.ts @@ -0,0 +1,105 @@ +import { type Vec3, vec3Add, vec3Cross, vec3Length, vec3Normalize, vec3Scale, vec3Sub } from './math'; + +function pushPoint(vertices: number[], point: Vec3): void { + vertices.push(point[0], point[1], point[2]); +} + +function perpendicularBasis(direction: Vec3): [Vec3, Vec3] { + const reference: Vec3 = Math.abs(direction[2]) < 0.9 ? [0, 0, 1] : [0, 1, 0]; + const first = vec3Normalize(vec3Cross(direction, reference)); + return [first, vec3Normalize(vec3Cross(direction, first))]; +} + +function ringPoint(center: Vec3, first: Vec3, second: Vec3, radius: number, angle: number): Vec3 { + return vec3Add( + center, + vec3Add(vec3Scale(first, Math.cos(angle) * radius), vec3Scale(second, Math.sin(angle) * radius)), + ); +} + +/** Appends a capped tube made from triangles. */ +export function appendTubeTriangles( + vertices: number[], + start: Vec3, + end: Vec3, + radius: number, + sides = 8, +): void { + const delta = vec3Sub(end, start); + if (radius <= 0 || vec3Length(delta) < 1e-6 || sides < 3) return; + const direction = vec3Normalize(delta); + const [first, second] = perpendicularBasis(direction); + + for (let side = 0; side < sides; side++) { + const angle = side * Math.PI * 2 / sides; + const nextAngle = (side + 1) * Math.PI * 2 / sides; + const startPoint = ringPoint(start, first, second, radius, angle); + const startNext = ringPoint(start, first, second, radius, nextAngle); + const endPoint = ringPoint(end, first, second, radius, angle); + const endNext = ringPoint(end, first, second, radius, nextAngle); + + pushPoint(vertices, startPoint); + pushPoint(vertices, endPoint); + pushPoint(vertices, endNext); + pushPoint(vertices, startPoint); + pushPoint(vertices, endNext); + pushPoint(vertices, startNext); + + pushPoint(vertices, start); + pushPoint(vertices, startNext); + pushPoint(vertices, startPoint); + pushPoint(vertices, end); + pushPoint(vertices, endPoint); + pushPoint(vertices, endNext); + } +} + +/** Appends a capped cone whose point is at `tip`. */ +export function appendConeTriangles( + vertices: number[], + base: Vec3, + tip: Vec3, + radius: number, + sides = 10, +): void { + const delta = vec3Sub(tip, base); + if (radius <= 0 || vec3Length(delta) < 1e-6 || sides < 3) return; + const direction = vec3Normalize(delta); + const [first, second] = perpendicularBasis(direction); + + for (let side = 0; side < sides; side++) { + const angle = side * Math.PI * 2 / sides; + const nextAngle = (side + 1) * Math.PI * 2 / sides; + const point = ringPoint(base, first, second, radius, angle); + const next = ringPoint(base, first, second, radius, nextAngle); + pushPoint(vertices, tip); + pushPoint(vertices, point); + pushPoint(vertices, next); + pushPoint(vertices, base); + pushPoint(vertices, next); + pushPoint(vertices, point); + } +} + +/** Appends an axis-aligned solid box centered on `center`. */ +export function appendBoxTriangles(vertices: number[], center: Vec3, halfSize: number): void { + if (halfSize <= 0) return; + const corners: Vec3[] = [ + [-1, -1, -1], [1, -1, -1], [1, 1, -1], [-1, 1, -1], + [-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1], + ].map(([x, y, z]) => [ + center[0] + x * halfSize, + center[1] + y * halfSize, + center[2] + z * halfSize, + ] as Vec3); + const faces = [ + [0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], + [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], + [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7], + ]; + for (const face of faces) { + pushPoint(vertices, corners[face[0]]); + pushPoint(vertices, corners[face[1]]); + pushPoint(vertices, corners[face[2]]); + } +} diff --git a/src/gizmo.ts b/src/gizmo.ts index 9aa7416..dc593a7 100644 --- a/src/gizmo.ts +++ b/src/gizmo.ts @@ -4,7 +4,8 @@ import { cloneTextureProjection, type BrushTextureProjection } from './brush'; import { PatchControlPoint } from './patch'; import { getSelectedBrushItems, getSelectedPatchItems } from './editor-selection'; import { createLineBuffer } from './gl-utils'; -import { scaleGeometryFromOriginals } from './editor-transforms'; +import { moveSelectedFaces, scaleGeometryFromOriginals } from './editor-transforms'; +import { appendBoxTriangles, appendConeTriangles, appendTubeTriangles } from './geometry-primitives'; import { DEFAULT_ORTHOGRAPHIC_SCALE, type Viewport3DProjection, @@ -27,6 +28,28 @@ function gizmoLength( : vec3Length(vec3Sub(center, cameraPos)) * 0.12; } +export function buildGizmoAxisTriangles(center: Vec3, axis: number, length: number, scaleMode: boolean): number[] { + const axes: Vec3[] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; + const direction = axes[axis]; + if (!direction || length <= 0) return []; + const vertices: number[] = []; + const tip = vec3Add(center, vec3Scale(direction, length)); + const tipSize = length * 0.15; + const shaftRadius = length * 0.028; + + if (scaleMode) { + const halfSize = tipSize * 0.5; + const shaftEnd = vec3Add(tip, vec3Scale(direction, -halfSize)); + appendTubeTriangles(vertices, center, shaftEnd, shaftRadius); + appendBoxTriangles(vertices, tip, halfSize); + } else { + const coneBase = vec3Add(tip, vec3Scale(direction, -tipSize * 1.8)); + appendTubeTriangles(vertices, center, coneBase, shaftRadius); + appendConeTriangles(vertices, coneBase, tip, tipSize * 0.65); + } + return vertices; +} + export class Gizmo { // GL resources vao: WebGLVertexArrayObject; @@ -87,42 +110,13 @@ export class Gizmo { // Gizmo length scales with distance from camera for consistent screen size const len = gizmoLength(center, cameraPos, projection, orthographicScale); - const tipSize = len * 0.15; - - const axes: Vec3[] = [[1, 0, 0], [0, 1, 0], [0, 0, 1]]; const colors: Vec3[] = [[1, 0.2, 0.2], [0.2, 1, 0.2], [0.4, 0.4, 1]]; const isScale = this.editor.gizmoMode === 'scale'; const verts: number[] = []; for (let a = 0; a < 3; a++) { - const dir = axes[a]; - const tip: Vec3 = vec3Add(center, vec3Scale(dir, len)); const start = verts.length / 3; - - // Main axis line - verts.push(center[0], center[1], center[2]); - verts.push(tip[0], tip[1], tip[2]); - - if (isScale) { - // Small cube at tip (3 line pairs for a box outline) - const s = tipSize; - for (let i = 0; i < 3; i++) { - const d: Vec3 = [0, 0, 0]; - d[i] = s; - verts.push(tip[0] - d[0], tip[1] - d[1], tip[2] - d[2]); - verts.push(tip[0] + d[0], tip[1] + d[1], tip[2] + d[2]); - } - } else { - // Arrowhead: two short lines from tip angled back - const perp1 = axes[(a + 1) % 3]; - const perp2 = axes[(a + 2) % 3]; - const back = vec3Add(tip, vec3Scale(dir, -tipSize * 2)); - for (const p of [perp1, vec3Scale(perp1, -1) as Vec3, perp2, vec3Scale(perp2, -1) as Vec3]) { - const wing = vec3Add(back, vec3Scale(p, tipSize)); - verts.push(tip[0], tip[1], tip[2]); - verts.push(wing[0], wing[1], wing[2]); - } - } + verts.push(...buildGizmoAxisTriangles(center, a, len, isScale)); const count = verts.length / 3 - start; this.segments.push({ start, count, color: colors[a] }); @@ -243,9 +237,11 @@ export class Gizmo { ? 'Move brush vertices' : this.editor.patchEditMode ? 'Move patch control points' + : this.editor.selectedFaces.length > 0 + ? 'Move brush faces' : this.editor.gizmoMode === 'scale' ? 'Scale selection' : 'Move selection'; this.editor.beginTransaction(label); - if (e.altKey && this.editor.gizmoMode === 'move') { + if (e.altKey && this.editor.gizmoMode === 'move' && this.editor.selectedFaces.length === 0) { this.editor.duplicateSelectionInPlace(); } this.snapshotTaken = true; @@ -316,7 +312,8 @@ export class Gizmo { const snapped = snapAxisDelta(worldDelta, refs, grid, abs, geo, threshold).delta; if (snapped !== 0) { const delta: Vec3 = vec3Scale(axis, snapped); - this.editor.moveSelection(delta); + if (this.editor.selectedFaces.length > 0) moveSelectedFaces(this.editor, delta); + else this.editor.moveSelection(delta); this.center = vec3Add(this.center, delta); this.dragLast = [e.clientX, e.clientY]; } diff --git a/src/gl-utils.ts b/src/gl-utils.ts index 4d747af..28f84ad 100644 --- a/src/gl-utils.ts +++ b/src/gl-utils.ts @@ -77,6 +77,28 @@ void main() { } `; +export const SCREEN_LINE_VERT_SRC = `#version 300 es +precision mediump float; +layout(location=0) in vec3 aStart; +layout(location=1) in vec3 aEnd; +layout(location=2) in vec2 aCorner; +uniform mat4 uPV; +uniform vec2 uHalfViewport; +void main() { + vec4 clipStart = uPV * vec4(aStart, 1.0); + vec4 clipEnd = uPV * vec4(aEnd, 1.0); + vec2 ndcStart = clipStart.xy / clipStart.w; + vec2 ndcEnd = clipEnd.xy / clipEnd.w; + vec2 screenDirection = (ndcEnd - ndcStart) * uHalfViewport; + vec2 normal = length(screenDirection) > 0.0001 + ? normalize(vec2(-screenDirection.y, screenDirection.x)) + : vec2(0.0, 1.0); + vec4 clipPosition = mix(clipStart, clipEnd, aCorner.x); + clipPosition.xy += normal * aCorner.y / uHalfViewport * clipPosition.w; + gl_Position = clipPosition; +} +`; + export const LINE_FRAG_SRC = `#version 300 es precision mediump float; uniform vec3 uColor; @@ -129,6 +151,22 @@ export function createLineBuffer(gl: WebGL2RenderingContext): GLBuffer { return { vao, vbo }; } +/** Create a VAO+VBO for fixed-width screen-space lines: start(3) + end(3) + corner(2). */ +export function createScreenLineBuffer(gl: WebGL2RenderingContext): GLBuffer { + const vao = gl.createVertexArray()!; + const vbo = gl.createBuffer()!; + gl.bindVertexArray(vao); + gl.bindBuffer(gl.ARRAY_BUFFER, vbo); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 3, gl.FLOAT, false, 32, 0); + gl.enableVertexAttribArray(1); + gl.vertexAttribPointer(1, 3, gl.FLOAT, false, 32, 12); + gl.enableVertexAttribArray(2); + gl.vertexAttribPointer(2, 2, gl.FLOAT, false, 32, 24); + gl.bindVertexArray(null); + return { vao, vbo }; +} + /** Create a VAO+VBO for solid geometry: pos(3) + normal(3) + uv(2) = 8 floats, stride 32 */ export function createSolidBuffer(gl: WebGL2RenderingContext): GLBuffer { const vao = gl.createVertexArray()!; diff --git a/src/main.ts b/src/main.ts index 38593cb..13a7ed7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,7 +17,7 @@ import { replaceStoredAssetConfiguration, } from './pak-storage'; import { TextureManager } from './textures'; -import { saveProjectConfiguration, type ProjectConfiguration } from './project-config'; +import type { ProjectConfiguration } from './project-config'; import { configuredBridgeUrl } from './live-bridge/configuration'; import { openUnreadReleaseNotesDialog } from './release-notes-dialog'; import { DocumentRecoveryService } from './document-recovery'; @@ -96,6 +96,9 @@ async function init() { const vpXY = new Viewport2D(xyCanvas, editor, 'xy'); const vpXZ = new Viewport2D(xzCanvas, editor, 'xz'); const vpYZ = new Viewport2D(yzCanvas, editor, 'yz'); + vpXY.getDepthCenter = () => (vpXZ.centerY + vpYZ.centerY) / 2; + vpXZ.getDepthCenter = () => (vpXY.centerY + vpYZ.centerX) / 2; + vpYZ.getDepthCenter = () => (vpXY.centerX + vpXZ.centerX) / 2; const vp3D = new Viewport3D(tdCanvas, editor); // Create UI @@ -326,7 +329,7 @@ async function init() { } }; - ui.onProjectConfigurationChanged = async (project: ProjectConfiguration) => { + const applyProjectConfigurationAssets = async (project: ProjectConfiguration) => { openArenaEnabled = project.assets.configured ? project.assets.openArenaEnabled : await loadOpenArenaEnabled(); const rebuilt = await rebuildWithStoredPaks(); if (!rebuilt) return; @@ -334,6 +337,8 @@ async function init() { ui.setTextureAssetStatus(description, rebuilt.names); editor.statusMessage = `Using ${description}`; }; + ui.onProjectConfigurationChanged = applyProjectConfigurationAssets; + editor.subscribeProjectConfigurationChanges(project => { void applyProjectConfigurationAssets(project); }); ui.onManagePakFiles = async () => { let assetLoading: ReturnType | null = null; @@ -379,13 +384,14 @@ async function init() { reportProgress('Saving asset configuration…'); await replaceStoredAssetConfiguration(ordered, result.openArenaEnabled); openArenaEnabled = result.openArenaEnabled; - editor.projectConfiguration.assets = { - ...editor.projectConfiguration.assets, + const project = structuredClone(editor.projectConfiguration); + project.assets = { + ...project.assets, archives: ordered.map(pak => pak.name), openArenaEnabled, configured: true, }; - saveProjectConfiguration(editor.projectConfiguration); + editor.updateProjectConfiguration(project, { label: 'Update project assets', notify: false }); assetLoading.update('Updating textures in the 3D view…', 1, 1); const installedTextureManager = installTextureManager(assets); await new Promise(resolve => requestAnimationFrame(() => resolve())); diff --git a/src/preferences-dialog.ts b/src/preferences-dialog.ts index aa80eab..8c77fe5 100644 --- a/src/preferences-dialog.ts +++ b/src/preferences-dialog.ts @@ -15,7 +15,6 @@ import { exportProjectConfiguration, importProjectConfiguration, normalizeProjectConfiguration, - saveProjectConfiguration, type ProjectConfiguration, } from './project-config'; import { refreshEditorThemeColors } from './theme-colors'; @@ -322,7 +321,7 @@ export function openProjectSettingsDialog(options: ProjectSettingsDialogOptions) status.className = 'preferences-status'; status.setAttribute('role', 'status'); - const projectSection = section('Project', 'Settings in this dialog apply only to the current project.'); + const projectSection = section('Project', 'Settings in this dialog are stored with the current .map file. Browser storage remains the fallback for older maps.'); const projectName = input(project.name); const basePath = input(project.game.basePath); const gameDir = input(project.game.gameDirectory); @@ -362,7 +361,7 @@ export function openProjectSettingsDialog(options: ProjectSettingsDialogOptions) try { const json = await chooseJson(); if (!json) return; project = importProjectConfiguration(json); - saveProjectConfiguration(project); editor.applyPreferences(editor.preferences, project); + editor.updateProjectConfiguration(project, { label: 'Import project settings' }); options.onApplied?.(project); overlay.remove(); openProjectSettingsDialog(options); } catch (error) { status.textContent = error instanceof Error ? error.message : String(error); } @@ -379,8 +378,7 @@ export function openProjectSettingsDialog(options: ProjectSettingsDialogOptions) entityDefinitions: { sources: lines(definitionSources.value) }, overrides: { ...project.overrides, gridSize: projectGrid.value ? Number(projectGrid.value) : undefined, gridSnapMode: projectSnap.value || undefined }, }); - saveProjectConfiguration(project); - editor.applyPreferences(editor.preferences, project); + editor.updateProjectConfiguration(project); options.onApplied?.(project); editor.statusMessage = 'Project settings saved'; overlay.remove(); diff --git a/src/project-config.ts b/src/project-config.ts index 2fec0ca..bb9f09c 100644 --- a/src/project-config.ts +++ b/src/project-config.ts @@ -1,8 +1,10 @@ import type { DisplayPreferences } from './display-policy'; +import type { Entity } from './entity'; import type { GlobalPreferences, GridSnapMode } from './preferences'; export const PROJECT_CONFIG_VERSION = 1; export const PROJECT_CONFIG_STORAGE_KEY = 'q3edit.project.current.v1'; +export const MAP_PROJECT_CONFIG_KEY = '_q3edit_project_config'; export interface ProjectConfiguration { version: typeof PROJECT_CONFIG_VERSION; @@ -109,6 +111,26 @@ export function importProjectConfiguration(json: string): ProjectConfiguration { return normalizeProjectConfiguration(parsed); } +export function serializeMapProjectConfiguration(project: ProjectConfiguration): string { + return JSON.stringify(normalizeProjectConfiguration(project)); +} + +export function readMapProjectConfiguration(entities: Entity[]): ProjectConfiguration | null { + const raw = entities.find(entity => entity.classname === 'worldspawn')?.properties[MAP_PROJECT_CONFIG_KEY]; + if (!raw) return null; + try { return importProjectConfiguration(raw); } + catch { return null; } +} + +export function writeMapProjectConfiguration(entities: Entity[], project: ProjectConfiguration): boolean { + const worldspawn = entities.find(entity => entity.classname === 'worldspawn'); + if (!worldspawn) return false; + const serialized = serializeMapProjectConfiguration(project); + if (worldspawn.properties[MAP_PROJECT_CONFIG_KEY] === serialized) return false; + worldspawn.properties[MAP_PROJECT_CONFIG_KEY] = serialized; + return true; +} + export function resolveProjectPreferences(global: GlobalPreferences, project: ProjectConfiguration): ResolvedProjectPreferences { return { gridSize: project.overrides.gridSize ?? global.editorDefaults.gridSize, diff --git a/src/release-notes/2026-08-04-project-settings.md b/src/release-notes/2026-08-04-project-settings.md new file mode 100644 index 0000000..d1b7d8a --- /dev/null +++ b/src/release-notes/2026-08-04-project-settings.md @@ -0,0 +1,15 @@ +--- +id: 2026-08-04-project-settings +title: August 4, 2026 — Portable Project Settings +date: 2026-08-04 +order: 1 +--- + +Project configuration now travels with each map, keeping editing and build behavior consistent when maps are reopened or shared. + +## Map-local project configuration + +- Project Settings are embedded in the saved `.map` file and restored automatically when the map opens. +- Build options, asset ordering, entity-definition sources, diagnostics preferences, and editor overrides round-trip with the document and follow undo, redo, and recovery. +- Q3Edit removes the embedded metadata from compiler input, while PK3 binary data remains in browser asset storage. +- Older maps without embedded settings continue to use the browser-stored project configuration as a fallback. diff --git a/src/release-notes/2026-08-05-editor-workflow.md b/src/release-notes/2026-08-05-editor-workflow.md new file mode 100644 index 0000000..f9766fc --- /dev/null +++ b/src/release-notes/2026-08-05-editor-workflow.md @@ -0,0 +1,21 @@ +--- +id: 2026-08-05-editor-workflow +title: August 5, 2026 — Editor Workflow Improvements +date: 2026-08-05 +order: 1 +--- + +Selection, geometry editing, and brush creation are more consistent across the 2D and 3D views. + +## Selection and geometry editing + +- Selecting a brush or face now selects its texture in the texture panel. +- Edges can be selected in the 3D view and are highlighted at a steady two-pixel screen width. +- The transform gizmo uses solid geometry for better visibility. +- Moving a selected face with the gizmo edits that face instead of moving the entire brush. +- Option-click selects faces while editing polygons in the 3D view. + +## Brush creation and tools + +- New brushes inherit their depth from the centers of the other two orthographic views instead of always starting on the world origin. +- Brush and entity tool options open on a second click, with a visible chevron indicating that the active tool has additional options. diff --git a/src/style.css b/src/style.css index 25a1e24..4ba03a0 100644 --- a/src/style.css +++ b/src/style.css @@ -1081,6 +1081,20 @@ textarea:focus-visible { pointer-events: none; } +.tool-btn.has-options::before { + content: ''; + position: absolute; + right: 2px; + bottom: 2px; + width: 0; + height: 0; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + border-top: 4px solid currentColor; + opacity: 0.72; + pointer-events: none; +} + /* Tooltip on hover */ .tool-btn[title]:hover::after { content: attr(title); diff --git a/src/ui-toolbar.ts b/src/ui-toolbar.ts index 35fb773..da8f257 100644 --- a/src/ui-toolbar.ts +++ b/src/ui-toolbar.ts @@ -10,6 +10,11 @@ export interface ToolbarContext { commands: CommandRegistry; } +export function modeToolPanelClickAction(active: boolean, panelOpen: boolean): 'activate' | 'open' | 'close' { + if (!active) return 'activate'; + return panelOpen ? 'close' : 'open'; +} + export function buildToolbar(ctx: ToolbarContext): void { const bar = document.getElementById('toolbar')!; const toolList = document.createElement('div'); @@ -142,6 +147,7 @@ export function buildToolbar(ctx: ToolbarContext): void { const closeCreateToolPanel = () => { createToolPanel.classList.remove('open'); createToolButton?.classList.remove('active-panel'); + createToolButton?.setAttribute('aria-expanded', 'false'); }; const openCreateToolPanel = () => { @@ -151,6 +157,7 @@ export function buildToolbar(ctx: ToolbarContext): void { setCreateToolButtonIcon(); createToolPanel.classList.add('open'); createToolButton.classList.add('active-panel'); + createToolButton.setAttribute('aria-expanded', 'true'); positionCreateToolPanel(); }; @@ -167,10 +174,13 @@ export function buildToolbar(ctx: ToolbarContext): void { const closeEntityToolPanel = () => { entityToolPanel.classList.remove('open'); entityToolButton?.classList.remove('active-panel'); + entityToolButton?.setAttribute('aria-expanded', 'false'); }; const updateEntityToolButtonTitle = (classname = ctx.editor.currentEntityClass) => { const shortcut = ctx.commands.getState('tool.entity').shortcut; - if (entityToolButton) entityToolButton.title = `Place Entity: ${classname}${shortcut ? ` (${formatShortcut(shortcut)})` : ''}`; + if (entityToolButton) { + entityToolButton.title = `Place Entity: ${classname}${shortcut ? ` (${formatShortcut(shortcut)})` : ''} · Click again for entity options`; + } }; const entityPicker = createEntityClassPicker(ctx.editor, { idPrefix: 'toolbar-entity-class', @@ -195,6 +205,7 @@ export function buildToolbar(ctx: ToolbarContext): void { entityPicker.refresh(); entityToolPanel.classList.add('open'); entityToolButton.classList.add('active-panel'); + entityToolButton.setAttribute('aria-expanded', 'true'); positionEntityToolPanel(); entityPicker.focus(); }; @@ -250,26 +261,31 @@ export function buildToolbar(ctx: ToolbarContext): void { dataset: { tool: tool.id }, onClick: () => { if (tool.id === 'create') { - if (ctx.editor.activeTool !== 'create') { + const action = modeToolPanelClickAction( + ctx.editor.activeTool === 'create', + createToolPanel.classList.contains('open'), + ); + if (action === 'activate') { + closeCreateToolPanel(); void ctx.commands.execute(tool.commandId); - openCreateToolPanel(); return; } - if (createToolPanel.classList.contains('open')) { - closeCreateToolPanel(); - } else { - openCreateToolPanel(); - } + if (action === 'close') closeCreateToolPanel(); + else openCreateToolPanel(); return; } if (tool.id === 'entity') { - if (ctx.editor.activeTool !== 'entity') { + const action = modeToolPanelClickAction( + ctx.editor.activeTool === 'entity', + entityToolPanel.classList.contains('open'), + ); + if (action === 'activate') { + closeEntityToolPanel(); void ctx.commands.execute(tool.commandId); - openEntityToolPanel(); return; } - if (entityToolPanel.classList.contains('open')) closeEntityToolPanel(); + if (action === 'close') closeEntityToolPanel(); else openEntityToolPanel(); return; } @@ -281,9 +297,17 @@ export function buildToolbar(ctx: ToolbarContext): void { }); if (tool.id === 'create') { createToolButton = btn; + createToolButton.classList.add('has-options'); + createToolButton.setAttribute('aria-haspopup', 'true'); + createToolButton.setAttribute('aria-controls', createToolPanel.id); + createToolButton.setAttribute('aria-expanded', 'false'); setCreateToolButtonIcon(); } else if (tool.id === 'entity') { entityToolButton = btn; + entityToolButton.classList.add('has-options'); + entityToolButton.setAttribute('aria-haspopup', 'true'); + entityToolButton.setAttribute('aria-controls', entityToolPanel.id); + entityToolButton.setAttribute('aria-expanded', 'false'); entityPicker.refresh(); refreshCommandState.push(updateEntityToolButtonTitle); } diff --git a/src/ui.ts b/src/ui.ts index 20aedcb..1a819e9 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1329,7 +1329,6 @@ export class UI { this.closeMenus(); openProjectSettingsDialog({ editor: this.editor, - onApplied: project => { void this.onProjectConfigurationChanged?.(project); }, }); } diff --git a/src/vertex.ts b/src/vertex.ts index 581bae8..2a2adb7 100644 --- a/src/vertex.ts +++ b/src/vertex.ts @@ -1,4 +1,4 @@ -import { Vec3, vec3Add, vec3Copy, vec3Sub, vec3Dot, vec3DistSq, vec3Cross, vec3Length, +import { Vec3, vec3Add, vec3Copy, vec3Sub, vec3Dot, vec3DistSq, vec3Cross, vec3Length, vec3Scale, vec3Min, vec3Max, planeFromPoints } from './math'; import { Brush, BrushFace, rebuildBrush, updateFacePointsFromPolygon } from './brush'; @@ -385,3 +385,62 @@ export function pickVertex3D( } return bestIdx; } + +export interface BrushEdgePick3D { + vertexIndices: [number, number]; + distSq: number; + rayT: number; +} + +/** Pick the closest brush edge to a 3D ray using the same distance-scaled tolerance as vertex picking. */ +export function pickEdge3D( + brush: Brush, + vertices: BrushVertex[], + rayOrigin: Vec3, + rayDir: Vec3, + threshold: number, +): BrushEdgePick3D | null { + const rayLength = vec3Length(rayDir); + if (rayLength < 1e-8) return null; + const direction = vec3Scale(rayDir, 1 / rayLength); + let best: BrushEdgePick3D | null = null; + + for (const edge of collectBrushEdges(brush, vertices)) { + const a = vertices[edge.vertexIndices[0]]?.position; + const b = vertices[edge.vertexIndices[1]]?.position; + if (!a || !b) continue; + const segment = vec3Sub(b, a); + const segmentLengthSq = vec3Dot(segment, segment); + if (segmentLengthSq < 1e-8) continue; + + const fromOrigin = vec3Sub(a, rayOrigin); + const segmentAlongRay = vec3Dot(segment, direction); + const originAlongRay = vec3Dot(fromOrigin, direction); + const segmentPerpendicular = vec3Sub(segment, vec3Scale(direction, segmentAlongRay)); + const originPerpendicular = vec3Sub(fromOrigin, vec3Scale(direction, originAlongRay)); + const perpendicularLengthSq = vec3Dot(segmentPerpendicular, segmentPerpendicular); + let segmentT = perpendicularLengthSq > 1e-8 + ? -vec3Dot(originPerpendicular, segmentPerpendicular) / perpendicularLengthSq + : 0; + segmentT = Math.max(0, Math.min(1, segmentT)); + + let point = vec3Add(a, vec3Scale(segment, segmentT)); + let rayT = vec3Dot(vec3Sub(point, rayOrigin), direction); + if (rayT < 0) { + segmentT = Math.max(0, Math.min(1, -vec3Dot(fromOrigin, segment) / segmentLengthSq)); + point = vec3Add(a, vec3Scale(segment, segmentT)); + rayT = 0; + } + + const rayPoint = vec3Add(rayOrigin, vec3Scale(direction, rayT)); + const distSq = vec3DistSq(point, rayPoint); + const scaledThreshold = threshold * Math.max(rayT, 1) * 0.01; + if (distSq > scaledThreshold * scaledThreshold) continue; + if (!best || rayT < best.rayT - 1e-5 || + (Math.abs(rayT - best.rayT) <= 1e-5 && distSq < best.distSq)) { + best = { vertexIndices: edge.vertexIndices, distSq, rayT }; + } + } + + return best; +} diff --git a/src/viewport2d-interaction.ts b/src/viewport2d-interaction.ts index 5bd2a2a..6e72777 100644 --- a/src/viewport2d-interaction.ts +++ b/src/viewport2d-interaction.ts @@ -38,6 +38,8 @@ export interface Viewport2DInteractionState { panCenterStart: [number, number]; hasDragged: boolean; moveSnapshotTaken: boolean; + moveAxisLock: 'h' | 'v' | null; + moveAppliedDelta: [number, number]; resizing: boolean; resizeEdges: ResizeEdges; resizeBrushes: { @@ -83,6 +85,7 @@ export interface Viewport2DInteractionContext { centerX: number; centerY: number; zoom: number; + getDepthCenter: () => number; interaction: Viewport2DInteractionState; screenToWorld: (sx: number, sy: number) => [number, number]; } @@ -98,6 +101,8 @@ export function createViewport2DInteractionState(): Viewport2DInteractionState { panCenterStart: [0, 0], hasDragged: false, moveSnapshotTaken: false, + moveAxisLock: null, + moveAppliedDelta: [0, 0], resizing: false, resizeEdges: { minH: false, maxH: false, minV: false, maxV: false }, resizeBrushes: [], @@ -262,7 +267,8 @@ export function handleViewport2DMouseDown(ctx: Viewport2DInteractionContext, e: if (ctx.editor.activeTool === 'create') { const snapped = snapPlanarPoint(ctx, wx, wy, e.ctrlKey, false); - snapped[ctx.axisDepth] = 0; + const grid = ctx.editor.effectiveGrid(e.ctrlKey); + snapped[ctx.axisDepth] = Math.round(ctx.getDepthCenter() / grid) * grid; ctx.editor.creating = true; ctx.editor.createStart = vec3Copy(snapped); ctx.editor.createEnd = vec3Copy(snapped); @@ -580,7 +586,8 @@ export function handleViewport2DMouseDown(ctx: Viewport2DInteractionContext, e: ctx.editor.clearSelection(); } - if (additive || !alreadySelected) { + const preserveSelectedForDirectionLock = e.shiftKey && !e.ctrlKey && !e.metaKey && alreadySelected; + if ((additive || !alreadySelected) && !preserveSelectedForDirectionLock) { if (picked.type === 'brush') { if (directGroupEditing) ctx.editor.selectBrushDirect(picked.entity, picked.brush, additive); else ctx.editor.selectBrush(picked.entity, picked.brush, additive); @@ -595,6 +602,8 @@ export function handleViewport2DMouseDown(ctx: Viewport2DInteractionContext, e: state.dragging = true; state.hasDragged = false; state.moveSnapshotTaken = false; + state.moveAxisLock = null; + state.moveAppliedDelta = [0, 0]; state.dragStart = [mx, my]; state.dragWorldStart = [wx, wy]; state.geoSnapTargets = ctx.editor.snapToGeometry ? ctx.editor.collectSnapTargets() : null; @@ -1031,8 +1040,17 @@ export function handleViewport2DMouseMove(ctx: Viewport2DInteractionContext, e: if (ctx.editor.selection.length === 0) return; - const dx = wx - state.dragWorldStart[0]; - const dy = wy - state.dragWorldStart[1]; + const totalDx = wx - state.dragWorldStart[0]; + const totalDy = wy - state.dragWorldStart[1]; + if (!e.shiftKey) { + state.moveAxisLock = null; + } else if (totalDx !== 0 || totalDy !== 0) { + state.moveAxisLock = Math.abs(totalDx) >= Math.abs(totalDy) ? 'h' : 'v'; + } + const allowH = state.moveAxisLock !== 'v'; + const allowV = state.moveAxisLock !== 'h'; + const dx = (allowH ? totalDx : 0) - state.moveAppliedDelta[0]; + const dy = (allowV ? totalDy : 0) - state.moveAppliedDelta[1]; const grid = ctx.editor.effectiveGrid(e.ctrlKey); const H = ctx.axisH; const V = ctx.axisV; @@ -1050,15 +1068,19 @@ export function handleViewport2DMouseMove(ctx: Viewport2DInteractionContext, e: const rawMaxV = bounds.maxs[V] + dy; const geoH = state.geoSnapTargets ? state.geoSnapTargets[H] : null; const geoV = state.geoSnapTargets ? state.geoSnapTargets[V] : null; - const rH = snapAxisDelta(dx, [rawMinH, rawMaxH, (rawMinH + rawMaxH) / 2], grid, abs, geoH, threshold); - const rV = snapAxisDelta(dy, [rawMinV, rawMaxV, (rawMinV + rawMaxV) / 2], grid, abs, geoV, threshold); - snappedDx = rH.delta; - snappedDy = rV.delta; - if (rH.snapLine !== null) state.geoSnapLines.push({ axis: 'h', value: rH.snapLine }); - if (rV.snapLine !== null) state.geoSnapLines.push({ axis: 'v', value: rV.snapLine }); + const rH = allowH + ? snapAxisDelta(dx, [rawMinH, rawMaxH, (rawMinH + rawMaxH) / 2], grid, abs, geoH, threshold) + : null; + const rV = allowV + ? snapAxisDelta(dy, [rawMinV, rawMaxV, (rawMinV + rawMaxV) / 2], grid, abs, geoV, threshold) + : null; + snappedDx = rH?.delta ?? dx; + snappedDy = rV?.delta ?? dy; + if (rH?.snapLine != null) state.geoSnapLines.push({ axis: 'h', value: rH.snapLine }); + if (rV?.snapLine != null) state.geoSnapLines.push({ axis: 'v', value: rV.snapLine }); } else { - snappedDx = Math.round(dx / grid) * grid; - snappedDy = Math.round(dy / grid) * grid; + snappedDx = allowH ? Math.round(dx / grid) * grid : dx; + snappedDy = allowV ? Math.round(dy / grid) * grid : dy; } if (snappedDx === 0 && snappedDy === 0) return; @@ -1075,9 +1097,9 @@ export function handleViewport2DMouseMove(ctx: Viewport2DInteractionContext, e: delta[ctx.axisH] = snappedDx; delta[ctx.axisV] = snappedDy; ctx.editor.moveSelection(delta); - state.dragWorldStart = [ - state.dragWorldStart[0] + snappedDx, - state.dragWorldStart[1] + snappedDy, + state.moveAppliedDelta = [ + state.moveAppliedDelta[0] + snappedDx, + state.moveAppliedDelta[1] + snappedDy, ]; } @@ -1241,8 +1263,9 @@ export function handleViewport2DMouseUp(ctx: Viewport2DInteractionContext, e: Mo mins[ctx.axisV] = Math.min(ctx.editor.createStart[ctx.axisV], ctx.editor.createEnd[ctx.axisV]); maxs[ctx.axisH] = Math.max(ctx.editor.createStart[ctx.axisH], ctx.editor.createEnd[ctx.axisH]); maxs[ctx.axisV] = Math.max(ctx.editor.createStart[ctx.axisV], ctx.editor.createEnd[ctx.axisV]); - mins[ctx.axisDepth] = 0; - maxs[ctx.axisDepth] = ctx.editor.createDepth; + const depthCenter = ctx.editor.createStart[ctx.axisDepth]; + mins[ctx.axisDepth] = depthCenter - ctx.editor.createDepth / 2; + maxs[ctx.axisDepth] = depthCenter + ctx.editor.createDepth / 2; const grid = ctx.editor.effectiveGrid(e.ctrlKey); if (maxs[ctx.axisH] - mins[ctx.axisH] >= grid && @@ -1257,6 +1280,8 @@ export function handleViewport2DMouseUp(ctx: Viewport2DInteractionContext, e: Mo ctx.editor.commitTransaction(); state.moveSnapshotTaken = false; } + state.moveAxisLock = null; + state.moveAppliedDelta = [0, 0]; state.dragging = false; } diff --git a/src/viewport2d.ts b/src/viewport2d.ts index 2003fc2..d534bb2 100644 --- a/src/viewport2d.ts +++ b/src/viewport2d.ts @@ -23,6 +23,7 @@ export class Viewport2D { centerX = 256; centerY = 128; zoom = 1; + getDepthCenter = (): number => 0; interaction = createViewport2DInteractionState(); diff --git a/src/viewport3d-geometry.ts b/src/viewport3d-geometry.ts index ab9722c..bf87baa 100644 --- a/src/viewport3d-geometry.ts +++ b/src/viewport3d-geometry.ts @@ -7,6 +7,7 @@ import { DrawGroup, EntityMarkerWireDraw, LightRadiusDraw } from './viewport3d-r import { buildModelGeometry } from './model-geometry'; import { bspOverlayLines } from './bsp-inspection'; import { lightVolumeSegments, resolveLightVolume } from './light-volume'; +import { collectBrushEdges } from './vertex'; const faceVertexCache = new WeakMap(); const patchVertexCache = new WeakMap(); @@ -67,6 +68,7 @@ export interface Viewport3DGeometryContext { lineVBO: WebGLBuffer; wireVBO: WebGLBuffer; faceSelVBO: WebGLBuffer; + vtxEdgeSelVBO: WebGLBuffer; vtxHandleVBO: WebGLBuffer; vtxHandleSelVBO: WebGLBuffer; lightRadiusVBO: WebGLBuffer; @@ -91,11 +93,31 @@ export interface Viewport3DGeometryBuild { wireCount: number; entityMarkerWireDraws: EntityMarkerWireDraw[]; faceSelCount: number; + vtxEdgeSelCount: number; vtxHandleCount: number; vtxHandleSelCount: number; lightRadiusDraws: LightRadiusDraw[]; } +export function selectedVertexEdgeQuadVertices(editor: Editor): number[] { + if (!editor.vertexMode) return []; + const vertices: number[] = []; + for (let dataIndex = 0; dataIndex < editor.vertexData.length; dataIndex++) { + const data = editor.vertexData[dataIndex]; + for (const edge of collectBrushEdges(data.brush, data.vertices)) { + const [aIndex, bIndex] = edge.vertexIndices; + if (!editor.isVertexSelected(dataIndex, aIndex) || !editor.isVertexSelected(dataIndex, bIndex)) continue; + const a = data.vertices[aIndex]?.position; + const b = data.vertices[bIndex]?.position; + if (!a || !b) continue; + for (const [along, side] of [[0, -1], [1, -1], [1, 1], [0, -1], [1, 1], [0, 1]]) { + vertices.push(...a, ...b, along, side); + } + } + } + return vertices; +} + export function buildViewport3DGeometry(ctx: Viewport3DGeometryContext): Viewport3DGeometryBuild { const tm = ctx.editor.textureManager; const textureTerrainMode = ctx.editor.patchEditMode && ctx.editor.terrainBrushMode === 'texture'; @@ -617,6 +639,11 @@ export function buildViewport3DGeometry(ctx: Viewport3DGeometryContext): Viewpor ctx.gl.bufferData(ctx.gl.ARRAY_BUFFER, new Float32Array(faceSelLineVerts), ctx.gl.DYNAMIC_DRAW); const faceSelCount = faceSelLineVerts.length / 3; + const vtxEdgeSelVerts = selectedVertexEdgeQuadVertices(ctx.editor); + ctx.gl.bindBuffer(ctx.gl.ARRAY_BUFFER, ctx.vtxEdgeSelVBO); + ctx.gl.bufferData(ctx.gl.ARRAY_BUFFER, new Float32Array(vtxEdgeSelVerts), ctx.gl.DYNAMIC_DRAW); + const vtxEdgeSelCount = vtxEdgeSelVerts.length / 8; + const vtxVerts: number[] = []; const vtxSelVerts: number[] = []; if (ctx.editor.vertexMode) { @@ -688,6 +715,7 @@ export function buildViewport3DGeometry(ctx: Viewport3DGeometryContext): Viewpor wireCount, entityMarkerWireDraws, faceSelCount, + vtxEdgeSelCount, vtxHandleCount, vtxHandleSelCount, lightRadiusDraws, diff --git a/src/viewport3d-render.ts b/src/viewport3d-render.ts index 7ade5e9..ac945ce 100644 --- a/src/viewport3d-render.ts +++ b/src/viewport3d-render.ts @@ -79,6 +79,11 @@ export interface Viewport3DRenderContext { linePVLoc: WebGLUniformLocation; lineColorLoc: WebGLUniformLocation; lineAlphaLoc: WebGLUniformLocation; + edgeProg: WebGLProgram; + edgePVLoc: WebGLUniformLocation; + edgeHalfViewportLoc: WebGLUniformLocation; + edgeColorLoc: WebGLUniformLocation; + edgeAlphaLoc: WebGLUniformLocation; solidVAO: WebGLVertexArrayObject; drawGroups: DrawGroup[]; clipBoxVAO: WebGLVertexArrayObject; @@ -114,6 +119,8 @@ export interface Viewport3DRenderContext { entityMarkerWireDraws: EntityMarkerWireDraw[]; faceSelVAO: WebGLVertexArrayObject; faceSelCount: number; + vtxEdgeSelVAO: WebGLVertexArrayObject; + vtxEdgeSelCount: number; vtxHandleVAO: WebGLVertexArrayObject; vtxHandleCount: number; vtxHandleSelVAO: WebGLVertexArrayObject; @@ -446,6 +453,18 @@ export function renderViewport3D(ctx: Viewport3DRenderContext): Mat4 { ctx.gl.enable(ctx.gl.DEPTH_TEST); } + if (showSelection && ctx.vtxEdgeSelCount > 0) { + ctx.gl.useProgram(ctx.edgeProg); + ctx.gl.uniformMatrix4fv(ctx.edgePVLoc, false, pv); + ctx.gl.uniform2f(ctx.edgeHalfViewportLoc, ctx.canvas.width / (2 * dpr), ctx.canvas.height / (2 * dpr)); + ctx.gl.uniform3f(ctx.edgeColorLoc, 1.0, 0xe0 / 255, 0x66 / 255); + ctx.gl.uniform1f(ctx.edgeAlphaLoc, 1.0); + ctx.gl.disable(ctx.gl.DEPTH_TEST); + ctx.gl.bindVertexArray(ctx.vtxEdgeSelVAO); + ctx.gl.drawArrays(ctx.gl.TRIANGLES, 0, ctx.vtxEdgeSelCount); + ctx.gl.enable(ctx.gl.DEPTH_TEST); + } + if (showSelection && (ctx.vtxHandleCount > 0 || ctx.vtxHandleSelCount > 0)) { ctx.gl.useProgram(ctx.lineProg); ctx.gl.uniformMatrix4fv(ctx.linePVLoc, false, pv); @@ -473,7 +492,7 @@ export function renderViewport3D(ctx: Viewport3DRenderContext): Mat4 { const c = seg.color; const bright = ctx.gizmo.dragging && ctx.gizmo.axis === i ? 1.5 : 1.0; ctx.gl.uniform3f(ctx.lineColorLoc, c[0] * bright, c[1] * bright, c[2] * bright); - ctx.gl.drawArrays(ctx.gl.LINES, seg.start, seg.count); + ctx.gl.drawArrays(ctx.gl.TRIANGLES, seg.start, seg.count); } ctx.gl.enable(ctx.gl.DEPTH_TEST); } diff --git a/src/viewport3d-selection.ts b/src/viewport3d-selection.ts index 076ae22..f79bce9 100644 --- a/src/viewport3d-selection.ts +++ b/src/viewport3d-selection.ts @@ -4,7 +4,7 @@ import { Brush, BrushFace } from './brush'; import { Entity } from './entity'; import { hasDirectGeometrySelection, isBrushDirectlySelected, isPatchDirectlySelected } from './editor-selection'; import { Patch } from './patch'; -import { pickVertex3D } from './vertex'; +import { pickEdge3D, pickVertex3D } from './vertex'; export interface Viewport3DSelectionContext { editor: Editor; @@ -53,6 +53,40 @@ function isGroupedGeometrySelection(ctx: Viewport3DSelectionContext, entity: Ent return entity !== ctx.editor.worldspawn && ctx.editor.hasEntityGeometry(entity); } +function selectVertexPair( + editor: Editor, + dataIndex: number, + vertexIndices: [number, number], + additive: boolean, +): void { + if (!additive) editor.clearVertexSelection(); + for (const vertexIndex of vertexIndices) { + if (!editor.isVertexSelected(dataIndex, vertexIndex)) editor.selectVertex(dataIndex, vertexIndex, true); + } +} + +function pickVertexModeFace( + editor: Editor, + rayOrigin: Vec3, + rayDir: Vec3, +): { dataIndex: number; faceIndex: number } | null { + let best: { dataIndex: number; faceIndex: number } | null = null; + let bestDistance = Infinity; + for (let dataIndex = 0; dataIndex < editor.vertexData.length; dataIndex++) { + const brush = editor.vertexData[dataIndex].brush; + for (let faceIndex = 0; faceIndex < brush.faces.length; faceIndex++) { + const polygon = brush.faces[faceIndex].polygon; + for (let index = 1; index < polygon.length - 1; index++) { + const distance = rayTriangleIntersect(rayOrigin, rayDir, polygon[0], polygon[index], polygon[index + 1]); + if (distance === null || distance >= bestDistance) continue; + bestDistance = distance; + best = { dataIndex, faceIndex }; + } + } + } + return best; +} + export function handleViewport3DPick(ctx: Viewport3DSelectionContext, e: MouseEvent): void { const [sx, sy] = ctx.dragStart; if (ctx.editor.activeTool === 'clip') { @@ -83,6 +117,12 @@ export function handleViewport3DPick(ctx: Viewport3DSelectionContext, e: MouseEv if (ctx.editor.vertexMode) { const { rayOrigin, rayDir } = ctx.getRay(sx, sy); const additive = e.ctrlKey || e.metaKey || e.shiftKey; + if (e.altKey) { + const hitFace = pickVertexModeFace(ctx.editor, rayOrigin, rayDir); + if (hitFace) ctx.editor.selectFaceVertices(hitFace.dataIndex, hitFace.faceIndex); + else if (!additive) ctx.editor.clearVertexSelection(); + return; + } let hitDi = -1; let hitVi = -1; for (let di = 0; di < ctx.editor.vertexData.length; di++) { @@ -93,8 +133,23 @@ export function handleViewport3DPick(ctx: Viewport3DSelectionContext, e: MouseEv break; } } + let hitEdgeDi = -1; + let hitEdge: [number, number] | null = null; + let hitEdgeRayT = Infinity; + if (hitDi < 0) { + for (let di = 0; di < ctx.editor.vertexData.length; di++) { + const data = ctx.editor.vertexData[di]; + const edge = pickEdge3D(data.brush, data.vertices, rayOrigin, rayDir, 8); + if (!edge || edge.rayT >= hitEdgeRayT) continue; + hitEdgeDi = di; + hitEdge = edge.vertexIndices; + hitEdgeRayT = edge.rayT; + } + } if (hitDi >= 0) { ctx.editor.selectVertex(hitDi, hitVi, additive); + } else if (hitEdgeDi >= 0 && hitEdge) { + selectVertexPair(ctx.editor, hitEdgeDi, hitEdge, additive); } else if (!additive) { ctx.editor.clearVertexSelection(); } @@ -197,7 +252,9 @@ export function handleViewport3DDoublePick(ctx: Viewport3DSelectionContext, e: M if (ctx.editor.vertexMode) { const { rayOrigin, rayDir } = ctx.getRay(sx, sy); for (let di = 0; di < ctx.editor.vertexData.length; di++) { - if (pickVertex3D(ctx.editor.vertexData[di].vertices, rayOrigin, rayDir, 8) >= 0) { + const data = ctx.editor.vertexData[di]; + if (pickVertex3D(data.vertices, rayOrigin, rayDir, 8) >= 0 || + pickEdge3D(data.brush, data.vertices, rayOrigin, rayDir, 8)) { return; } } diff --git a/src/viewport3d.ts b/src/viewport3d.ts index dd19e93..eda600a 100644 --- a/src/viewport3d.ts +++ b/src/viewport3d.ts @@ -7,8 +7,8 @@ import { Brush, BrushFace } from './brush'; import { Entity } from './entity'; import { Patch } from './patch'; import { - VERT_SRC, FRAG_SRC, LINE_VERT_SRC, LINE_FRAG_SRC, - createProgram, createLineBuffer, createSolidBuffer, + VERT_SRC, FRAG_SRC, LINE_VERT_SRC, LINE_FRAG_SRC, SCREEN_LINE_VERT_SRC, + createProgram, createLineBuffer, createScreenLineBuffer, createSolidBuffer, } from './gl-utils'; import { Gizmo } from './gizmo'; import { WalkState, VIEWHEIGHT } from './q3-movement'; @@ -93,6 +93,11 @@ export class Viewport3D { private linePVLoc!: WebGLUniformLocation; private lineColorLoc!: WebGLUniformLocation; private lineAlphaLoc!: WebGLUniformLocation; + private edgeProg!: WebGLProgram; + private edgePVLoc!: WebGLUniformLocation; + private edgeHalfViewportLoc!: WebGLUniformLocation; + private edgeColorLoc!: WebGLUniformLocation; + private edgeAlphaLoc!: WebGLUniformLocation; private solidVAO!: WebGLVertexArrayObject; private solidVBO!: WebGLBuffer; @@ -151,6 +156,9 @@ export class Viewport3D { private faceSelVBO!: WebGLBuffer; private faceSelCount = 0; + private vtxEdgeSelVAO!: WebGLVertexArrayObject; + private vtxEdgeSelVBO!: WebGLBuffer; + private vtxEdgeSelCount = 0; private vtxHandleVAO!: WebGLVertexArrayObject; private vtxHandleVBO!: WebGLBuffer; private vtxHandleCount = 0; @@ -496,6 +504,12 @@ export class Viewport3D { this.lineColorLoc = gl.getUniformLocation(this.lineProg, 'uColor')!; this.lineAlphaLoc = gl.getUniformLocation(this.lineProg, 'uAlpha')!; + this.edgeProg = createProgram(gl, SCREEN_LINE_VERT_SRC, LINE_FRAG_SRC); + this.edgePVLoc = gl.getUniformLocation(this.edgeProg, 'uPV')!; + this.edgeHalfViewportLoc = gl.getUniformLocation(this.edgeProg, 'uHalfViewport')!; + this.edgeColorLoc = gl.getUniformLocation(this.edgeProg, 'uColor')!; + this.edgeAlphaLoc = gl.getUniformLocation(this.edgeProg, 'uAlpha')!; + const solid = createSolidBuffer(gl); this.solidVAO = solid.vao; this.solidVBO = solid.vbo; @@ -531,6 +545,8 @@ export class Viewport3D { this.wireVAO = wire.vao; this.wireVBO = wire.vbo; const faceSel = createLineBuffer(gl); this.faceSelVAO = faceSel.vao; this.faceSelVBO = faceSel.vbo; + const vtxEdgeSel = createScreenLineBuffer(gl); + this.vtxEdgeSelVAO = vtxEdgeSel.vao; this.vtxEdgeSelVBO = vtxEdgeSel.vbo; const vtxH = createLineBuffer(gl); this.vtxHandleVAO = vtxH.vao; this.vtxHandleVBO = vtxH.vbo; const vtxHS = createLineBuffer(gl); @@ -571,6 +587,7 @@ export class Viewport3D { lineVBO: this.lineVBO, wireVBO: this.wireVBO, faceSelVBO: this.faceSelVBO, + vtxEdgeSelVBO: this.vtxEdgeSelVBO, vtxHandleVBO: this.vtxHandleVBO, vtxHandleSelVBO: this.vtxHandleSelVBO, lightRadiusVBO: this.lightRadiusVBO, @@ -593,6 +610,7 @@ export class Viewport3D { this.wireCount = result.wireCount; this.entityMarkerWireDraws = result.entityMarkerWireDraws; this.faceSelCount = result.faceSelCount; + this.vtxEdgeSelCount = result.vtxEdgeSelCount; this.vtxHandleCount = result.vtxHandleCount; this.vtxHandleSelCount = result.vtxHandleSelCount; this.lightRadiusDraws = result.lightRadiusDraws; @@ -657,6 +675,11 @@ export class Viewport3D { linePVLoc: this.linePVLoc, lineColorLoc: this.lineColorLoc, lineAlphaLoc: this.lineAlphaLoc, + edgeProg: this.edgeProg, + edgePVLoc: this.edgePVLoc, + edgeHalfViewportLoc: this.edgeHalfViewportLoc, + edgeColorLoc: this.edgeColorLoc, + edgeAlphaLoc: this.edgeAlphaLoc, solidVAO: this.solidVAO, drawGroups: this.drawGroups, clipBoxVAO: this.clipBoxVAO, @@ -692,6 +715,8 @@ export class Viewport3D { entityMarkerWireDraws: this.entityMarkerWireDraws, faceSelVAO: this.faceSelVAO, faceSelCount: this.faceSelCount, + vtxEdgeSelVAO: this.vtxEdgeSelVAO, + vtxEdgeSelCount: this.vtxEdgeSelCount, vtxHandleVAO: this.vtxHandleVAO, vtxHandleCount: this.vtxHandleCount, vtxHandleSelVAO: this.vtxHandleSelVAO, diff --git a/tests/editor-document.test.ts b/tests/editor-document.test.ts index 187e794..923cbbd 100644 --- a/tests/editor-document.test.ts +++ b/tests/editor-document.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from 'vitest'; import { Editor } from '../src/editor'; import { loadMapAsync } from '../src/editor-document'; import { parseMapWithDiagnostics } from '../src/mapfile'; +import { MAP_PROJECT_CONFIG_KEY } from '../src/project-config'; describe('editor map loading', () => { test('keeps parser diagnostics available and reports them in the status', () => { @@ -117,4 +118,46 @@ brushDef3 expect(editor.worldspawn.properties.message).toBe('second'); vi.unstubAllGlobals(); }); + + test('embeds project settings, restores them on open, and excludes them from compile maps', () => { + const editor = new Editor(); + const project = structuredClone(editor.projectConfiguration); + project.name = 'Map-local project'; + project.game.gameDirectory = 'arena'; + project.assets.archives = ['pak0.pk3', 'arena.pk3']; + project.compile.bspArgs = ['-meta']; + project.overrides.gridSize = 32; + + editor.updateProjectConfiguration(project, { notify: false }); + const mapText = editor.serializeMap(); + expect(mapText).toContain(`"${MAP_PROJECT_CONFIG_KEY}"`); + expect(editor.serializeCompileMap()).not.toContain(MAP_PROJECT_CONFIG_KEY); + expect(editor.hasUnsavedChanges).toBe(true); + + const reopened = new Editor(); + reopened.loadMap(mapText); + expect(reopened.projectConfiguration).toEqual(project); + expect(reopened.gridSize).toBe(32); + expect(reopened.hasUnsavedChanges).toBe(false); + }); + + test('restores project settings and embedded metadata through undo and redo', () => { + const editor = new Editor(); + const original = structuredClone(editor.projectConfiguration); + const changed = structuredClone(original); + changed.name = 'Undoable project'; + changed.compile.lightArgs = ['-fast']; + + editor.updateProjectConfiguration(changed, { notify: false }); + expect(editor.projectConfiguration).toEqual(changed); + expect(editor.worldspawn.properties[MAP_PROJECT_CONFIG_KEY]).toBeTruthy(); + + editor.undo(); + expect(editor.projectConfiguration).toEqual(original); + expect(editor.worldspawn.properties[MAP_PROJECT_CONFIG_KEY]).toBeUndefined(); + + editor.redo(); + expect(editor.projectConfiguration).toEqual(changed); + expect(editor.worldspawn.properties[MAP_PROJECT_CONFIG_KEY]).toBeTruthy(); + }); }); diff --git a/tests/face-editing.test.ts b/tests/face-editing.test.ts index 294be87..a1a65c4 100644 --- a/tests/face-editing.test.ts +++ b/tests/face-editing.test.ts @@ -9,6 +9,7 @@ import { weldSelectedVertices, } from '../src/editor-vertex'; import { createEntity } from '../src/entity'; +import { moveSelectedFaces } from '../src/editor-transforms'; import { collectBrushEdges, collectBrushVertices, @@ -48,6 +49,21 @@ describe('face and edge editing', () => { expect(validateBrush(brush).valid).toBe(true); }); + test('moves only the selected face through the gizmo transform path', () => { + const editor = new Editor(); + const brush = createBoxBrush([0, 0, 0], [64, 64, 64], 'base_wall/concrete'); + editor.worldspawn.brushes.push(brush); + const face = brush.faces.find(candidate => candidate.plane.normal[0] > 0.9)!; + editor.selectFace(editor.worldspawn, brush, face); + + moveSelectedFaces(editor, [16, 0, 0]); + + expect(brush.maxs[0]).toBeCloseTo(80); + expect(brush.mins[0]).toBeCloseTo(0); + expect(editor.selectionCenter()?.[0]).toBeCloseTo(80); + expect(validateBrush(brush).valid).toBe(true); + }); + test('inserts and selects a midpoint on a connected edge', () => { const editor = editorWithBox(); const data = editor.vertexData[0]; diff --git a/tests/gizmo-geometry.test.ts b/tests/gizmo-geometry.test.ts new file mode 100644 index 0000000..a2abcd5 --- /dev/null +++ b/tests/gizmo-geometry.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { buildGizmoAxisTriangles } from '../src/gizmo'; + +describe('solid gizmo geometry', () => { + it('builds a filled move arrow with a shaft and arrowhead', () => { + const vertices = buildGizmoAxisTriangles([0, 0, 0], 0, 100, false); + + expect(vertices.length).toBeGreaterThan(9); + expect(vertices.length % 9).toBe(0); + expect(vertices.filter((_, index) => index % 3 === 0)).toContain(100); + }); + + it('builds a filled scale handle with a shaft and box', () => { + const vertices = buildGizmoAxisTriangles([10, 20, 30], 2, 100, true); + const zCoordinates = vertices.filter((_, index) => index % 3 === 2); + + expect(vertices.length).toBeGreaterThan(9); + expect(vertices.length % 9).toBe(0); + expect(Math.max(...zCoordinates)).toBeCloseTo(137.5); + expect(Math.min(...zCoordinates)).toBeCloseTo(30); + }); +}); diff --git a/tests/project-config.test.ts b/tests/project-config.test.ts index 109b821..52d55a7 100644 --- a/tests/project-config.test.ts +++ b/tests/project-config.test.ts @@ -1,13 +1,17 @@ import { describe, expect, it } from 'vitest'; +import { createEntity } from '../src/entity'; import { DEFAULT_GLOBAL_PREFERENCES } from '../src/preferences'; import { DEFAULT_PROJECT_CONFIGURATION, + MAP_PROJECT_CONFIG_KEY, PROJECT_CONFIG_STORAGE_KEY, importProjectConfiguration, loadProjectConfiguration, normalizeGameDirectory, + readMapProjectConfiguration, resolveProjectPreferences, saveProjectConfiguration, + writeMapProjectConfiguration, } from '../src/project-config'; class MemoryStorage { @@ -56,4 +60,25 @@ describe('project configuration', () => { expect(normalizeGameDirectory('../baseq3')).toBe('baseq3'); expect(normalizeGameDirectory('mods/tinygame')).toBe('baseq3'); }); + + it('round-trips project configuration through worldspawn metadata', () => { + const worldspawn = createEntity('worldspawn'); + const project = structuredClone(DEFAULT_PROJECT_CONFIGURATION); + project.name = 'Embedded arena project'; + project.compile.bspArgs = ['-meta', '-samplesize', '8']; + project.assets.archives = ['pak0.pk3', 'arena.pk3']; + + expect(writeMapProjectConfiguration([worldspawn], project)).toBe(true); + expect(worldspawn.properties[MAP_PROJECT_CONFIG_KEY]).toBeTruthy(); + expect(readMapProjectConfiguration([worldspawn])).toEqual(project); + expect(writeMapProjectConfiguration([worldspawn], project)).toBe(false); + }); + + it('ignores malformed or unsupported embedded project configuration', () => { + const worldspawn = createEntity('worldspawn'); + worldspawn.properties[MAP_PROJECT_CONFIG_KEY] = 'not json'; + expect(readMapProjectConfiguration([worldspawn])).toBeNull(); + worldspawn.properties[MAP_PROJECT_CONFIG_KEY] = '{"version":2}'; + expect(readMapProjectConfiguration([worldspawn])).toBeNull(); + }); }); diff --git a/tests/selection-texture-sync.test.ts b/tests/selection-texture-sync.test.ts new file mode 100644 index 0000000..f973129 --- /dev/null +++ b/tests/selection-texture-sync.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createBoxBrush } from '../src/brush'; +import { Editor } from '../src/editor'; +import { createFlatPatch } from '../src/patch'; + +describe('selection texture synchronization', () => { + it('makes a selected face current and locates it without editing the map', () => { + const editor = new Editor(); + const brush = createBoxBrush([0, 0, 0], [64, 64, 64], 'common/caulk'); + brush.faces[0].texture = 'textures/base_wall/metal'; + editor.worldspawn.brushes.push(brush); + const before = editor.serializeMap(); + const locate = vi.fn(); + editor.onLocateTexture = locate; + + editor.selectFace(editor.worldspawn, brush, brush.faces[0]); + + expect(editor.currentTexture).toBe('base_wall/metal'); + expect(locate).toHaveBeenCalledWith('base_wall/metal'); + expect(editor.serializeMap()).toBe(before); + expect(editor.hasUnsavedChanges).toBe(false); + }); + + it('makes the shared texture of a uniform brush current', () => { + const editor = new Editor(); + const brush = createBoxBrush([0, 0, 0], [64, 64, 64], 'textures/base_floor/tile'); + editor.worldspawn.brushes.push(brush); + const locate = vi.fn(); + editor.onLocateTexture = locate; + + editor.selectBrush(editor.worldspawn, brush); + + expect(editor.currentTexture).toBe('base_floor/tile'); + expect(locate).toHaveBeenCalledWith('base_floor/tile'); + }); + + it('leaves the current texture unchanged for a mixed-texture brush', () => { + const editor = new Editor(); + const brush = createBoxBrush([0, 0, 0], [64, 64, 64], 'base_wall/metal'); + brush.faces[0].texture = 'base_trim/edge'; + editor.worldspawn.brushes.push(brush); + editor.currentTexture = 'common/caulk'; + const locate = vi.fn(); + editor.onLocateTexture = locate; + + editor.selectBrush(editor.worldspawn, brush); + + expect(editor.currentTexture).toBe('common/caulk'); + expect(locate).not.toHaveBeenCalled(); + }); + + it('makes a selected patch texture current', () => { + const editor = new Editor(); + const patch = createFlatPatch([0, 0, 0], [64, 64, 0], 'textures/base_trim/edge'); + editor.worldspawn.patches.push(patch); + const locate = vi.fn(); + editor.onLocateTexture = locate; + + editor.selectPatch(editor.worldspawn, patch); + + expect(editor.currentTexture).toBe('base_trim/edge'); + expect(locate).toHaveBeenCalledWith('base_trim/edge'); + }); +}); diff --git a/tests/ui-toolbar.test.ts b/tests/ui-toolbar.test.ts new file mode 100644 index 0000000..49c1c19 --- /dev/null +++ b/tests/ui-toolbar.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { modeToolPanelClickAction } from '../src/ui-toolbar'; + +describe('mode toolbar options interaction', () => { + it('activates a mode without opening options, then toggles options on later clicks', () => { + expect(modeToolPanelClickAction(false, false)).toBe('activate'); + expect(modeToolPanelClickAction(true, false)).toBe('open'); + expect(modeToolPanelClickAction(true, true)).toBe('close'); + }); +}); diff --git a/tests/viewport2d-interaction.test.ts b/tests/viewport2d-interaction.test.ts index 0628958..87892a6 100644 --- a/tests/viewport2d-interaction.test.ts +++ b/tests/viewport2d-interaction.test.ts @@ -9,7 +9,11 @@ import { type Viewport2DInteractionContext, } from '../src/viewport2d-interaction'; -function mouseEvent(clientX: number, clientY: number): MouseEvent { +function mouseEvent( + clientX: number, + clientY: number, + modifiers: Partial> = {}, +): MouseEvent { return { button: 0, clientX, @@ -18,10 +22,15 @@ function mouseEvent(clientX: number, clientY: number): MouseEvent { metaKey: false, shiftKey: false, altKey: false, + ...modifiers, } as MouseEvent; } -function interactionContext(editor: Editor): Viewport2DInteractionContext { +function interactionContext( + editor: Editor, + axes: { h: number; v: number; depth: number } = { h: 0, v: 1, depth: 2 }, + depthCenter = 0, +): Viewport2DInteractionContext { const parentElement = { style: { cursor: '' } }; const canvas = { clientWidth: 256, @@ -32,19 +41,39 @@ function interactionContext(editor: Editor): Viewport2DInteractionContext { return { canvas, editor, - axisH: 0, - axisV: 1, - axisDepth: 2, + axisH: axes.h, + axisV: axes.v, + axisDepth: axes.depth, axisLabels: ['X', 'Y'], centerX: 0, centerY: 0, zoom: 1, + getDepthCenter: () => depthCenter, interaction: createViewport2DInteractionState(), screenToWorld: (x, y) => [x, y], }; } describe('2D viewport selection interaction', () => { + it.each([ + ['XY', { h: 0, v: 1, depth: 2 }], + ['XZ', { h: 0, v: 2, depth: 1 }], + ['YZ', { h: 1, v: 2, depth: 0 }], + ] as const)('centers brushes created in the %s view on the orthogonal view centers', (_name, axes) => { + const editor = new Editor(); + editor.activeTool = 'create'; + editor.createDepth = 64; + const ctx = interactionContext(editor, axes, 160); + + handleViewport2DMouseDown(ctx, mouseEvent(16, 32)); + handleViewport2DMouseMove(ctx, mouseEvent(80, 96)); + handleViewport2DMouseUp(ctx, mouseEvent(80, 96)); + + const brush = editor.worldspawn.brushes[0]; + expect(brush.mins[axes.depth]).toBe(128); + expect(brush.maxs[axes.depth]).toBe(192); + }); + it('starts a marquee on locked geometry and selects unlocked objects in the dragged area', () => { const editor = new Editor(); const lockedBrush = createBoxBrush([0, 0, 0], [32, 32, 32]); @@ -66,4 +95,33 @@ describe('2D viewport selection interaction', () => { expect(editor.isSelected(lockedBrush)).toBe(false); expect(editor.isSelected(selectableBrush)).toBe(true); }); + + it('locks a selected object move to the current dominant direction from mouse-down', () => { + const editor = new Editor(); + const brush = createBoxBrush([0, 0, 0], [32, 32, 32]); + editor.worldspawn.brushes.push(brush); + editor.selectBrush(editor.worldspawn, brush); + + const ctx = interactionContext(editor); + handleViewport2DMouseDown(ctx, mouseEvent(16, 16, { shiftKey: true })); + + expect(editor.isSelected(brush)).toBe(true); + + handleViewport2DMouseMove(ctx, mouseEvent(40, 24, { shiftKey: true })); + expect(brush.mins.slice(0, 2)).toEqual([32, 0]); + + // Crossing the diagonal switches the lock while preserving the mouse-down anchor. + handleViewport2DMouseMove(ctx, mouseEvent(48, 80, { shiftKey: true })); + expect(brush.mins.slice(0, 2)).toEqual([0, 64]); + + // Releasing Shift removes the constraint during the same drag. + handleViewport2DMouseMove(ctx, mouseEvent(48, 80)); + expect(brush.mins.slice(0, 2)).toEqual([32, 64]); + + // Reapplying a horizontal lock returns to the Y position from mouse-down. + handleViewport2DMouseMove(ctx, mouseEvent(96, 40, { shiftKey: true })); + expect(brush.mins.slice(0, 2)).toEqual([80, 0]); + + handleViewport2DMouseUp(ctx, mouseEvent(96, 40)); + }); }); diff --git a/tests/viewport3d-edge-selection.test.ts b/tests/viewport3d-edge-selection.test.ts new file mode 100644 index 0000000..895e6e7 --- /dev/null +++ b/tests/viewport3d-edge-selection.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createBoxBrush } from '../src/brush'; +import { Editor } from '../src/editor'; +import type { Vec3 } from '../src/math'; +import { collectBrushEdges, pickEdge3D, type BrushVertex } from '../src/vertex'; +import { selectedVertexEdgeQuadVertices } from '../src/viewport3d-geometry'; +import { handleViewport3DDoublePick, handleViewport3DPick, type Viewport3DSelectionContext } from '../src/viewport3d-selection'; + +function edgeRay(vertices: BrushVertex[], vertexIndices: [number, number]): { rayOrigin: Vec3; rayDir: Vec3 } { + const a = vertices[vertexIndices[0]].position; + const b = vertices[vertexIndices[1]].position; + const midpoint: Vec3 = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2]; + const center: Vec3 = [32, 32, 32]; + const outward: Vec3 = [midpoint[0] - center[0], midpoint[1] - center[1], midpoint[2] - center[2]]; + const length = Math.hypot(...outward); + const normal: Vec3 = outward.map(value => value / length) as Vec3; + return { + rayOrigin: midpoint.map((value, axis) => value + normal[axis] * 100) as Vec3, + rayDir: normal.map(value => -value) as Vec3, + }; +} + +function vertexModeContext(editor: Editor, rayOrigin: Vec3, rayDir: Vec3): Viewport3DSelectionContext { + return { + editor, + dragStart: [0, 0], + getRay: () => ({ rayOrigin, rayDir }), + pickBrushAt: () => null, + pickPatchAt: () => null, + pickEntityAt: () => null, + }; +} + +describe('3D vertex edge selection', () => { + it('picks a brush edge from a ray through the middle of the segment', () => { + const brush = createBoxBrush([0, 0, 0], [64, 64, 64], 'common/caulk'); + const editor = new Editor(); + editor.worldspawn.brushes.push(brush); + editor.selectBrush(editor.worldspawn, brush); + editor.enterVertexMode(); + const data = editor.vertexData[0]; + const edge = collectBrushEdges(data.brush, data.vertices)[0]; + const ray = edgeRay(data.vertices, edge.vertexIndices); + + const hit = pickEdge3D(data.brush, data.vertices, ray.rayOrigin, ray.rayDir, 8); + + expect(new Set(hit?.vertexIndices)).toEqual(new Set(edge.vertexIndices)); + }); + + it('selects both edge endpoints while keeping vertex mode active on double-click', () => { + const editor = new Editor(); + const brush = createBoxBrush([0, 0, 0], [64, 64, 64], 'common/caulk'); + editor.worldspawn.brushes.push(brush); + editor.selectBrush(editor.worldspawn, brush); + editor.enterVertexMode(); + const data = editor.vertexData[0]; + const edge = collectBrushEdges(data.brush, data.vertices)[0]; + const ray = edgeRay(data.vertices, edge.vertexIndices); + const context = vertexModeContext(editor, ray.rayOrigin, ray.rayDir); + const event = { ctrlKey: false, metaKey: false, shiftKey: false } as MouseEvent; + + handleViewport3DPick(context, event); + + expect(editor.vertexSelection).toEqual(edge.vertexIndices.map(vertexIndex => ({ dataIndex: 0, vertexIndex }))); + const requestExit = vi.fn(); + editor.onRequestExitVertexMode = requestExit; + handleViewport3DDoublePick(context, event); + expect(requestExit).not.toHaveBeenCalled(); + }); + + it('selects every vertex of a face with Option-click in vertex mode', () => { + const editor = new Editor(); + const brush = createBoxBrush([0, 0, 0], [64, 64, 64], 'common/caulk'); + editor.worldspawn.brushes.push(brush); + editor.selectBrush(editor.worldspawn, brush); + editor.enterVertexMode(); + const context = vertexModeContext(editor, [128, 32, 32], [-1, 0, 0]); + const event = { altKey: true, ctrlKey: false, metaKey: false, shiftKey: false } as MouseEvent; + + handleViewport3DPick(context, event); + + expect(editor.vertexSelection).toHaveLength(4); + const selectedFaceIndices = editor.vertexSelection.map(selection => + editor.vertexData[selection.dataIndex].vertices[selection.vertexIndex].faceIndices, + ); + expect(selectedFaceIndices.every(faceIndices => faceIndices.some(faceIndex => + brush.faces[faceIndex].plane.normal[0] > 0.9, + ))).toBe(true); + }); + + it('builds a solid highlight only for edges whose endpoints are selected', () => { + const editor = new Editor(); + const brush = createBoxBrush([0, 0, 0], [64, 64, 64], 'common/caulk'); + editor.worldspawn.brushes.push(brush); + editor.selectBrush(editor.worldspawn, brush); + editor.enterVertexMode(); + const data = editor.vertexData[0]; + const edge = collectBrushEdges(data.brush, data.vertices)[0]; + editor.vertexSelection = edge.vertexIndices.map(vertexIndex => ({ dataIndex: 0, vertexIndex })); + const triangles = selectedVertexEdgeQuadVertices(editor); + expect(triangles).toHaveLength(6 * 8); + expect(triangles.filter((_, index) => index % 8 === 6)).toEqual([0, 1, 1, 0, 1, 0]); + editor.vertexSelection = [{ dataIndex: 0, vertexIndex: edge.vertexIndices[0] }]; + expect(selectedVertexEdgeQuadVertices(editor)).toEqual([]); + }); +});