Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/brush-primitive-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 6 additions & 6 deletions src/diagnostics-dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
}));
Expand All @@ -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);
Expand Down
42 changes: 42 additions & 0 deletions src/editor-document.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -30,6 +38,7 @@ export interface DocumentHistoryAuxiliary {
unsupportedMapConstructs: Editor['unsupportedMapConstructs'];
savedDocumentRevision: number;
documentSessionStartedAt: number;
projectConfiguration: ProjectConfiguration;
}

export function captureDocumentHistoryAuxiliary(editor: Editor): DocumentHistoryAuxiliary {
Expand All @@ -40,6 +49,7 @@ export function captureDocumentHistoryAuxiliary(editor: Editor): DocumentHistory
unsupportedMapConstructs: structuredClone(editor.unsupportedMapConstructs),
savedDocumentRevision: editor.savedDocumentRevision,
documentSessionStartedAt: editor.documentSessionStartedAt,
projectConfiguration: structuredClone(editor.projectConfiguration),
};
}

Expand All @@ -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<Editor, number>();
Expand Down Expand Up @@ -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 = [];
Expand All @@ -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',
Expand Down Expand Up @@ -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;
Expand All @@ -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}`;
Expand All @@ -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 = [];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
8 changes: 8 additions & 0 deletions src/editor-queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
43 changes: 42 additions & 1 deletion src/editor-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -203,6 +243,7 @@ export function selectFace(
} else {
editor.selection = [{ type: 'face', entity, brush, face }];
}
adoptSelectionTexture(editor);
editor.redrawRequested = true;
}

Expand All @@ -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 {
Expand Down
34 changes: 33 additions & 1 deletion src/editor-transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Brush, Set<Brush['faces'][number]>>();
for (const item of selectedFaces) {
const faces = facesByBrush.get(item.brush) ?? new Set<Brush['faces'][number]>();
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;
Expand Down
20 changes: 20 additions & 0 deletions src/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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:')) {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading