Govern task context and advance execution pointer
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-08-20 06:02:43 -04:00
parent 380cbed4ff
commit 10640aeb3c
984 changed files with 543475 additions and 327 deletions

Binary file not shown.

View File

@@ -873,7 +873,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
</div>
<div className="property-section"><h3>Transform</h3><label> <output>0.000, 0.000, 0.000</output></label><label> <output>0°, 0°, 0°</output></label><label> <output>1.000, 1.000, 1.000</output></label></div>
{activeNode ? <div className="property-section"><h3>Object & Hierarchy</h3><label> <input aria-label="对象名称" value={renameValue} onChange={(event) => setRenameValue(event.target.value)} /></label><div className="property-actions"><button type="button" onClick={() => renameValue && onCommand({ type: "renameId", id: activeNode.id, name: renameValue })}></button><button type="button" onClick={() => onCommand({ type: "applyObjectTransform", objectId: activeNode.id })}></button><button type="button" onClick={() => onCommand({ type: "setObjectOrigin", objectId: activeNode.id, mode: "GEOMETRY" })}></button></div><label>Collection <select aria-label="移动到 Collection" value="" onChange={(event) => event.target.value && onCommand({ type: "moveObjectToCollection", objectId: activeNode.id, collectionId: event.target.value })}><option value="">...</option>{snapshot?.collections.map((collection) => <option key={collection.id} value={collection.id}>{collection.name}</option>)}</select></label></div> : null}
<div className="property-section"><h3>Viewport Display</h3><label> <span className="swatch" /></label><label> <input type="checkbox" defaultChecked /></label></div>
<div className="property-section"><h3>Viewport Display</h3><label> <span className="swatch" /></label><label> <input aria-label="可见性" type="checkbox" defaultChecked /></label></div>
{activeCurve && curveTopologyOperatorGate("TOGGLE_CYCLIC").status === "READY" ? <div className="property-section" data-testid="curve-topology-editor" data-ready-operators="TOGGLE_CYCLIC" data-curve-id={activeCurve.id} data-cyclic-u={(activeCurve.cyclicU ?? []).join(",")} data-spline-types={(activeCurve.splineTypes ?? []).join(",")} data-point-count={activeCurve.pointCount}><h3>Curve Topology</h3><label>Spline <select aria-label="Curve spline" value={Math.min(curveSplineIndex, Math.max(0, activeCurve.splineCount - 1))} onChange={(event) => setCurveSplineIndex(Number(event.target.value))}>{Array.from({ length: activeCurve.splineCount }, (_, index) => <option key={index} value={index}>{index + 1} {activeCurve.splineTypes?.[index] ?? "UNKNOWN"}</option>)}</select></label><label>Cyclic U <input aria-label="Curve cyclic U" type="checkbox" checked={activeCurve.cyclicU?.[curveSplineIndex] ?? false} onChange={toggleCurveCyclic} /></label><output data-testid="curve-cyclic-state">{activeCurve.cyclicU?.[curveSplineIndex] ? "Cyclic" : "Open"}</output>{curveTopologyError ? <output role="alert">{curveTopologyError}</output> : null}</div> : null}
{activeMesh ? <div className="property-section"><h3>UV Maps</h3><label> UV <select aria-label="活动 UV Map" value={activeMesh.activeUVMap ?? ""} onChange={(event) => event.target.value && onCommand({ type: "setActiveUVMap", meshId: activeMesh.id, name: event.target.value })}><option value="">None</option>{activeMesh.uvLayers?.map((layer) => <option key={layer.name} value={layer.name}>{layer.name}</option>)}</select></label><div className="property-actions"><button type="button" onClick={() => onCommand({ type: "createUVMap", meshId: activeMesh.id, name: `UVMap.${(activeMesh.uvLayers?.length ?? 0) + 1}` })}> UV</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "PLANAR" })}>Planar</button><button type="button" disabled={selectedFaceIndices.length === 0} onClick={() => onCommand({ type: "unwrapUV", meshId: activeMesh.id, faceIndices: selectedFaceIndices, method: "CUBE" })}>Cube</button></div></div> : null}
{activeGreasePencil ? <div

View File

@@ -80,7 +80,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.operator-results { display: grid; gap: 2px; margin-top: 6px; }.operator-results button { padding: 8px 10px; text-align: left; color: #d9dce1; background: transparent; border-radius: 3px; }.operator-results button:hover { color: #fff; background: #a55325; }
.outliner-tools input:focus-visible, .operator-search input:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
.file-button:has(input:focus-visible) { outline: 2px solid #f2a15b; outline-offset: 1px; }
@media (max-width: 800px) {
@media (max-width: 900px) {
.topbar { min-width: 0; overflow-x: auto; scrollbar-width: none; }
.topbar::-webkit-scrollbar { display: none; }
.topbar > *, .topbar-actions button { flex: none; white-space: nowrap; }

View File

@@ -9,6 +9,7 @@ import type { OffscreenViewportRequest, OffscreenViewportResponse } from "./offs
import { PBR_PROFILE, PBR_SHADOW_PROFILE, PBR_TONE_MAPPING } from "./pbr";
import { resolveViewportPixelMetrics, viewportNDC } from "../../../protocol/viewport-dpr";
import { observePointerEvent } from "../../../protocol/pointer-contract";
import { createInputModalState, reduceInputModal, type InputModalState } from "../../../protocol/input-modal";
import type { NonMeshElementKind } from "./nonmesh";
import type { GreasePencilPointPreview, GreasePencilPointRef } from "./grease-pencil";
import type { CurveGizmoFrameIR, CurveGizmoHandleIR } from "../../../protocol/nonmesh-interaction";
@@ -88,6 +89,8 @@ export class OffscreenViewportRenderer implements ViewportBackend {
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void;
private readonly onGreasePencilMarqueeSelect?: (result: GreasePencilMarqueeResultIR, additive: boolean) => void;
private pointer: { id: number; x: number; y: number; moved: boolean } | null = null;
private inputModal: InputModalState = createInputModalState();
private touchNavigationActive = false;
private lastSnapshot: SceneSnapshotIR | null = null;
private lastGeometryBuffers: MeshGeometryBuffer[] | null = null;
private lastNonMeshGeometryBuffers: NonMeshGeometryChunk[] | null = null;
@@ -134,6 +137,7 @@ export class OffscreenViewportRenderer implements ViewportBackend {
canvas.dataset.pbrProfile = PBR_PROFILE;
canvas.dataset.toneMapping = PBR_TONE_MAPPING;
canvas.dataset.shadowMap = PBR_SHADOW_PROFILE;
this.publishInputModal();
}
setSnapshot(snapshot: SceneSnapshotIR, geometryBuffers: MeshGeometryBuffer[] = [], nonMeshGeometryBuffers: NonMeshGeometryChunk[] = []): void {
@@ -252,8 +256,8 @@ export class OffscreenViewportRenderer implements ViewportBackend {
}
private pointerDown = (event: PointerEvent): void => {
try { this.canvas.dataset.lastPointer = JSON.stringify(observePointerEvent(event)); }
catch { this.canvas.dataset.lastPointer = "BLOCKED"; }
const observation = this.recordInputModalEvent(event);
if (observation?.pointerType === "touch" && this.inputModal.activePointerIds.length >= 2) this.touchNavigationActive = true;
this.pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, moved: false };
try {
this.canvas.setPointerCapture(event.pointerId);
@@ -274,8 +278,17 @@ export class OffscreenViewportRenderer implements ViewportBackend {
};
private pointerUp = (event: PointerEvent): void => {
try { this.canvas.dataset.lastPointer = JSON.stringify(observePointerEvent(event)); }
catch { this.canvas.dataset.lastPointer = "BLOCKED"; }
const observation = this.recordInputModalEvent(event);
const wasTouchNavigation = this.touchNavigationActive;
if (observation?.pointerType === "touch" && this.inputModal.activePointerIds.length === 0) this.touchNavigationActive = false;
if (!observation) {
this.pointer = null;
return;
}
if (event.type === "pointercancel" || observation.pointerType === "pen" || wasTouchNavigation) {
this.pointer = null;
return;
}
if (!this.pointer || this.pointer.id !== event.pointerId) return;
if (!this.pointer.moved) {
const bounds = this.canvas.getBoundingClientRect();
@@ -285,6 +298,37 @@ export class OffscreenViewportRenderer implements ViewportBackend {
this.pointer = null;
};
private recordInputModalEvent(event: PointerEvent): ReturnType<typeof observePointerEvent> | null {
try {
const observation = observePointerEvent(event);
this.canvas.dataset.lastPointer = JSON.stringify(observation);
if (observation.pointerType === "touch" || observation.pointerType === "pen") {
this.inputModal = reduceInputModal(this.inputModal, {
type: event.type as "pointerdown" | "pointerup" | "pointercancel",
pointerType: observation.pointerType,
pointerId: observation.pointerId,
});
this.publishInputModal();
}
return observation;
}
catch {
this.canvas.dataset.lastPointer = "BLOCKED";
this.canvas.dataset.inputModalError = "INPUT_MODAL_EVENT_BLOCKED";
return null;
}
}
private publishInputModal(): void {
this.canvas.dataset.inputModalSchemaVersion = String(this.inputModal.schemaVersion);
this.canvas.dataset.inputModalKind = this.inputModal.kind;
this.canvas.dataset.inputModalActivePointerIds = this.inputModal.activePointerIds.join(",");
this.canvas.dataset.inputModalCancelled = this.inputModal.cancelled ? "1" : "0";
this.canvas.dataset.inputModalNavigationRevision = String(this.inputModal.navigationRevision);
this.canvas.dataset.inputModalMainCommitCount = String(this.inputModal.mainCommitCount);
this.canvas.dataset.inputModalError = "";
}
private wheel = (event: WheelEvent): void => {
event.preventDefault();
this.worker.postMessage({ type: "orbit", deltaX: 0, deltaY: 0, zoom: event.deltaY } satisfies OffscreenViewportRequest);

View File

@@ -71,6 +71,7 @@ import { validatePaintDepthVisibilityRequest, type PaintDepthVisibilityRequestIR
import { samplePaintDepthVisibilityGPU } from "./paint-depth-visibility";
import { resolveViewportPixelMetrics, viewportNDC } from "../../../protocol/viewport-dpr";
import { observePointerEvent } from "../../../protocol/pointer-contract";
import { createInputModalState, reduceInputModal, type InputModalState } from "../../../protocol/input-modal";
export function collectMeshInstanceGroups(snapshot: SceneSnapshotIR, minimumSize = 2): Map<string, string[]> {
const groups = new Map<string, string[]>();
@@ -104,6 +105,7 @@ export class ViewportRenderer {
private readonly textureStore = new GPUTextureStore("THREE_WEBGL2");
private readonly raycaster = new Raycaster();
private readonly pointer = new Vector2();
private inputModal: InputModalState = createInputModalState();
private readonly onSelect?: (objectId: string, additive: boolean) => void;
private readonly onElementSelect?: (meshId: string, mode: MeshElementMode, index: number, additive: boolean, nonMeshKind?: NonMeshElementKind) => void;
private readonly onGreasePencilPointSelect?: (point: GreasePencilPointRef, additive: boolean, baseSelectionRevision: number) => void;
@@ -173,12 +175,14 @@ export class ViewportRenderer {
this.resizeObserver.observe(canvas);
this.canvas.addEventListener("click", this.handleClick);
this.canvas.addEventListener("pointerdown", this.handlePointerObservation);
this.canvas.addEventListener("pointerup", this.handlePointerObservation);
this.canvas.addEventListener("pointercancel", this.handlePointerObservation);
this.canvas.addEventListener("webglcontextlost", this.handleContextLost);
this.canvas.addEventListener("webglcontextrestored", this.handleContextRestored);
this.resize();
this.controls.update();
this.publishCameraState();
this.publishInputModal();
this.renderLoop();
}
@@ -821,6 +825,8 @@ export class ViewportRenderer {
if ("pointerType" in event) {
try { this.canvas.dataset.lastPointer = JSON.stringify(observePointerEvent(event as MouseEvent & { pointerType?: string; pointerId?: number; pressure?: number; tiltX?: number; tiltY?: number; buttons?: number; type?: string })); }
catch { this.canvas.dataset.lastPointer = "BLOCKED"; }
const pointerType = (event as MouseEvent & { pointerType?: string }).pointerType;
if ((pointerType === "touch" || pointerType === "pen") && this.inputModal.cancelled) return;
}
const bounds = this.canvas.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) return;
@@ -897,10 +903,34 @@ export class ViewportRenderer {
};
private handlePointerObservation = (event: PointerEvent): void => {
try { this.canvas.dataset.lastPointer = JSON.stringify(observePointerEvent(event)); }
catch { this.canvas.dataset.lastPointer = "BLOCKED"; }
try {
const observation = observePointerEvent(event);
this.canvas.dataset.lastPointer = JSON.stringify(observation);
if (observation.pointerType === "touch" || observation.pointerType === "pen") {
this.inputModal = reduceInputModal(this.inputModal, {
type: event.type as "pointerdown" | "pointerup" | "pointercancel",
pointerType: observation.pointerType,
pointerId: observation.pointerId,
});
this.publishInputModal();
}
}
catch {
this.canvas.dataset.lastPointer = "BLOCKED";
this.canvas.dataset.inputModalError = "INPUT_MODAL_EVENT_BLOCKED";
}
};
private publishInputModal(): void {
this.canvas.dataset.inputModalSchemaVersion = String(this.inputModal.schemaVersion);
this.canvas.dataset.inputModalKind = this.inputModal.kind;
this.canvas.dataset.inputModalActivePointerIds = this.inputModal.activePointerIds.join(",");
this.canvas.dataset.inputModalCancelled = this.inputModal.cancelled ? "1" : "0";
this.canvas.dataset.inputModalNavigationRevision = String(this.inputModal.navigationRevision);
this.canvas.dataset.inputModalMainCommitCount = String(this.inputModal.mainCommitCount);
this.canvas.dataset.inputModalError = "";
}
private renderLoop = (): void => {
if (this.disposed) return;
if (!this.contextLost) {
@@ -965,6 +995,7 @@ export class ViewportRenderer {
this.resizeObserver.disconnect();
this.canvas.removeEventListener("click", this.handleClick);
this.canvas.removeEventListener("pointerdown", this.handlePointerObservation);
this.canvas.removeEventListener("pointerup", this.handlePointerObservation);
this.canvas.removeEventListener("pointercancel", this.handlePointerObservation);
this.canvas.removeEventListener("webglcontextlost", this.handleContextLost);
this.canvas.removeEventListener("webglcontextrestored", this.handleContextRestored);

Binary file not shown.

View File

@@ -59,6 +59,9 @@
"test:physics-cache-family": "node --test tests/unit/physics-cache-family.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/physics-cache-family.spec.ts",
"test:m10-domain-gates": "node --test tests/unit/geometry-nodes.test.mjs tests/unit/shader-compiler.test.mjs tests/unit/nla.test.mjs tests/unit/simulation-cache.test.mjs && playwright test --config playwright.config.ts --workers=1 tests/e2e/m10-domain-browser-gates.spec.ts",
"test:browser": "playwright test --config playwright.release.config.ts",
"test:task-context": "node --test tests/unit/task-context.test.mjs && node ../tools/web/check-task-context.mjs",
"test:context-governance": "node --test tests/unit/task-context.test.mjs tests/unit/context-governance.test.mjs && node ../tools/web/check-context-governance.mjs",
"task:context": "node ../tools/web/print-task-context.mjs",
"test:cross-browser": "npm run test:browser",
"test:golden": "node ../tools/web/run-blender-golden.mjs",
"test:collapse-ratios": "node ../tools/web/check-collapse-ratios.mjs",
@@ -213,6 +216,26 @@
"test:chromium-ime-guard": "node --test tests/unit/ime-composition.test.mjs && node ../tools/web/check-chromium-ime-guard.mjs",
"test:chromium-keymap-fixture": "node --test tests/unit/keyboard-contract.test.mjs && node ../tools/web/check-chromium-keymap-fixture.mjs",
"test:chromium-input-modal": "node --test tests/unit/input-modal.test.mjs && node ../tools/web/check-chromium-input-modal.mjs",
"test:chromium-layout": "node ../tools/web/check-chromium-layout.mjs",
"test:chromium-accessibility": "node ../tools/web/check-chromium-accessibility.mjs",
"test:blender-rna-inventory": "node ../tools/web/check-blender-rna-datablock-inventory.mjs",
"test:blender-operator-inventory": "node ../tools/web/check-blender-operator-inventory.mjs",
"test:blender-family-inventory": "node ../tools/web/check-blender-family-inventory.mjs",
"test:blender-format-inventory": "node ../tools/web/check-blender-format-inventory.mjs",
"test:blender-editor-inventory": "node ../tools/web/check-blender-editor-inventory.mjs",
"test:blender-core-inventory": "node ../tools/web/check-blender-core-inventory.mjs",
"test:blender-parity-map": "node ../tools/web/check-blender-parity-map.mjs",
"test:blender-gap-audit": "node ../tools/web/check-blender-gap-audit.mjs",
"test:blender-local-exact-audit": "node ../tools/web/check-blender-contract-audit.mjs M15-02C",
"test:blender-local-equivalent-audit": "node ../tools/web/check-blender-contract-audit.mjs M15-02D",
"test:blender-server-exact-audit": "node ../tools/web/check-blender-contract-audit.mjs M15-02E",
"test:blender-unknown-data-audit": "node ../tools/web/check-blender-contract-audit.mjs M15-02F",
"test:blender-next-task-plan": "node ../tools/web/check-blender-next-task-plan.mjs",
"test:blender-next-task-spec": "node ../tools/web/check-blender-next-task-spec.mjs",
"test:blender-task-graph": "node ../tools/web/check-blender-task-graph.mjs",
"test:blender-task-stats": "node ../tools/web/check-blender-task-stats.mjs",
"test:blender-zero-gap-gate": "node ../tools/web/check-blender-zero-gap-gate.mjs",
"test:generated-gap": "node ../tools/web/check-generated-gap.mjs",
"test:firefox-quick": "npm run typecheck && node ../tools/web/check-firefox-quick.mjs",
"test:script-main-reader": "node ../tools/web/check-script-main-reader.mjs",
"test:scripting-isolation": "node ../tools/web/check-scripting-isolation.mjs",

View File

@@ -11,6 +11,12 @@ export interface InputModalState {
mainCommitCount: number;
}
export interface InputModalEvent {
type: "pointerdown" | "pointerup" | "pointercancel";
pointerType: "touch" | "pen";
pointerId: number;
}
export function createInputModalState(): InputModalState {
return { schemaVersion: 1, kind: "NONE", activePointerIds: [], cancelled: false, navigationRevision: 0, mainCommitCount: 0 };
}
@@ -39,3 +45,15 @@ export function commitPenStroke(state: InputModalState, pointerId: number): Inpu
if (state.kind !== "PEN_STROKE" || !state.activePointerIds.includes(pointerId) || state.cancelled) return state;
return { ...state, kind: "NONE", activePointerIds: [], mainCommitCount: state.mainCommitCount + 1 };
}
export function reduceInputModal(state: InputModalState, event: InputModalEvent): InputModalState {
if (!Number.isSafeInteger(event.pointerId) || event.pointerId < 0) throw new Error("POINTER_ID_INVALID");
if (event.pointerType === "touch") {
if (event.type === "pointerdown") return beginTouch(state, event.pointerId);
if (event.type === "pointerup") return endTouch(state, event.pointerId);
return cancelInputModal(state);
}
if (event.type === "pointerdown") return beginPenStroke(state, event.pointerId);
if (event.type === "pointerup") return commitPenStroke(state, event.pointerId);
return cancelInputModal(state);
}

View File

@@ -377,6 +377,68 @@ export interface VFontResourceIR {
sha256?: string;
}
export interface SoundResourceIR {
id: string;
name: string;
sourcePath: string;
packed: boolean;
packedByteLength?: number;
volume: number;
pitch: number;
audioChannels: number;
sampleRate: number;
}
export interface SpeakerResourceIR {
id: string;
name: string;
soundId?: string;
volumeMax: number;
volumeMin: number;
distanceMax: number;
distanceReference: number;
attenuation: number;
coneAngleOuter: number;
coneAngleInner: number;
coneVolumeOuter: number;
volume: number;
pitch: number;
}
export interface TextResourceIR {
id: string;
name: string;
source: string;
sourceSha256: string;
byteLength: number;
lineCount: number;
internal: boolean;
sourcePath?: string;
isDirty: boolean;
useModule: boolean;
}
export interface TextureResourceIR {
id: string;
name: string;
type: number;
noiseScale: number;
noiseDepth: number;
intensity: number;
contrast: number;
saturation: number;
}
export interface WindowManagerResourceIR {
id: string;
name: string;
presetName: string;
windowCount: number;
interfaceLocked: boolean;
}
export interface WorkspaceResourceIR { id: string; name: string; screenCount: number }
export interface NonMeshVolumePropertiesIR {
displayDensity: number;
interpolation: "NEAREST" | "LINEAR";
@@ -488,6 +550,9 @@ export interface SceneSnapshotIR {
lights: LightIR[];
worlds: WorldIR[];
images: ImageIR[];
brushes?: BrushIR[];
lineStyles?: FreestyleLineStyleIR[];
lattices?: LatticeIR[];
nonMeshData?: NonMeshDataIR[];
vfonts?: VFontResourceIR[];
greasePencils?: GreasePencilDataIR[];
@@ -499,6 +564,13 @@ export interface SceneSnapshotIR {
scriptSources?: ScriptSourceInventoryIR;
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
physicsSimulation?: PhysicsSimulationManifestIR;
particleSettings?: ParticleSettingsIR[];
sounds?: SoundResourceIR[];
speakers?: SpeakerResourceIR[];
texts?: TextResourceIR[];
textures?: TextureResourceIR[];
windowManager?: WindowManagerResourceIR;
workspaces?: WorkspaceResourceIR[];
geometryNodeGraphs?: GeometryNodeGraphIR[];
libraries?: Array<{
id: string;
@@ -520,6 +592,66 @@ export interface SceneSnapshotIR {
frame: { current: number; start: number; end: number };
}
export interface ParticleSettingsIR {
id: string;
name: string;
type: number;
from: number;
distribution: number;
physicsType: number;
totalParticles: number;
start: number;
end: number;
lifetime: number;
size: number;
drawSize: number;
}
export interface BrushIR {
id: string;
name: string;
size: number;
alpha: number;
hardness: number;
spacing: number;
jitter: number;
sculptTool: number;
}
export interface FreestyleLineStyleIR {
id: string;
name: string;
color: [number, number, number];
alpha: number;
thickness: number;
chaining: number;
caps: number;
dashed: boolean;
modifierCounts: { color: number; alpha: number; thickness: number; geometry: number };
}
export type LatticeInterpolationIR = "KEY_LINEAR" | "KEY_CARDINAL" | "KEY_BSPLINE" | "KEY_CATMULL_ROM";
export interface LatticePointIR {
coDeform: [number, number, number];
weight: number;
selected: boolean;
}
export interface LatticeIR {
id: string;
name: string;
dimensions: [number, number, number];
pointCount: number;
interpolation: [LatticeInterpolationIR, LatticeInterpolationIR, LatticeInterpolationIR];
useOutside: boolean;
activePoint: number;
vertexGroup: string;
status: "AVAILABLE" | "BLOCKED";
points?: LatticePointIR[];
errorCode?: "LATTICE_DIMENSIONS_INVALID" | "LATTICE_POINTS_UNREADABLE";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -564,7 +696,218 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
requireArray(value.lights, "lights");
requireArray(value.worlds, "worlds");
requireArray(value.images, "images");
if (value.brushes !== undefined) {
const brushes = requireArray(value.brushes, "brushes");
for (const [index, brush] of brushes.entries()) {
if (!isRecord(brush)) throw new Error(`SceneIR.brushes[${index}] must be an object`);
requireString(brush.id, `brushes[${index}].id`);
requireString(brush.name, `brushes[${index}].name`);
const size = requireNumber(brush.size, `brushes[${index}].size`);
const alpha = requireNumber(brush.alpha, `brushes[${index}].alpha`);
const hardness = requireNumber(brush.hardness, `brushes[${index}].hardness`);
const spacing = requireNumber(brush.spacing, `brushes[${index}].spacing`);
const jitter = requireNumber(brush.jitter, `brushes[${index}].jitter`);
const sculptTool = requireNumber(brush.sculptTool, `brushes[${index}].sculptTool`);
if (!Number.isSafeInteger(size) || size < 0 || size > 100000 || alpha < 0 || alpha > 1 || hardness < 0 || hardness > 1 || !Number.isSafeInteger(spacing) || spacing < 1 || spacing > 1000 || jitter < 0 || jitter > 1 || !Number.isSafeInteger(sculptTool) || sculptTool < 0) {
throw new Error(`SceneIR.brushes[${index}] is outside the bounded brush contract`);
}
}
}
if (value.lineStyles !== undefined) {
const lineStyles = requireArray(value.lineStyles, "lineStyles");
for (const [index, lineStyle] of lineStyles.entries()) {
if (!isRecord(lineStyle)) throw new Error(`SceneIR.lineStyles[${index}] must be an object`);
requireString(lineStyle.id, `lineStyles[${index}].id`);
requireString(lineStyle.name, `lineStyles[${index}].name`);
requireTuple(lineStyle.color, 3, `lineStyles[${index}].color`);
const alpha = requireNumber(lineStyle.alpha, `lineStyles[${index}].alpha`);
const thickness = requireNumber(lineStyle.thickness, `lineStyles[${index}].thickness`);
requireNumber(lineStyle.chaining, `lineStyles[${index}].chaining`);
requireNumber(lineStyle.caps, `lineStyles[${index}].caps`);
requireBoolean(lineStyle.dashed, `lineStyles[${index}].dashed`);
if (!isRecord(lineStyle.modifierCounts)) throw new Error(`SceneIR.lineStyles[${index}].modifierCounts must be an object`);
for (const field of ["color", "alpha", "thickness", "geometry"] as const) {
const count = requireNumber(lineStyle.modifierCounts[field], `lineStyles[${index}].modifierCounts.${field}`);
if (!Number.isSafeInteger(count) || count < 0 || count > 1024) throw new Error(`SceneIR.lineStyles[${index}].modifierCounts.${field} is outside budget`);
}
if (alpha < 0 || alpha > 1 || thickness < 0 || thickness > 10000) throw new Error(`SceneIR.lineStyles[${index}] is outside bounds`);
}
}
if (value.lattices !== undefined) {
const lattices = requireArray(value.lattices, "lattices");
if (lattices.length > 4096) throw new Error("SceneIR.lattices exceeds the data-block budget");
const ids = new Set<string>();
for (const [index, lattice] of lattices.entries()) {
if (!isRecord(lattice)) throw new Error(`SceneIR.lattices[${index}] must be an object`);
const id = requireString(lattice.id, `lattices[${index}].id`);
if (!id.startsWith("lattice:") || ids.has(id)) throw new Error(`SceneIR.lattices[${index}].id is invalid`);
ids.add(id);
requireString(lattice.name, `lattices[${index}].name`);
if (!Array.isArray(lattice.dimensions) || lattice.dimensions.length !== 3 || lattice.dimensions.some((item) => !Number.isSafeInteger(item) || item < 1 || item > 64)) throw new Error(`SceneIR.lattices[${index}].dimensions is invalid`);
const pointCount = requireNumber(lattice.pointCount, `lattices[${index}].pointCount`);
const expectedCount = (lattice.dimensions as number[]).reduce((product, item) => product * item, 1);
if (!Number.isSafeInteger(pointCount) || pointCount !== expectedCount || pointCount > 64 * 64 * 64) throw new Error(`SceneIR.lattices[${index}].pointCount is invalid`);
const interpolation = ["KEY_LINEAR", "KEY_CARDINAL", "KEY_BSPLINE", "KEY_CATMULL_ROM"];
if (!Array.isArray(lattice.interpolation) || lattice.interpolation.length !== 3 || lattice.interpolation.some((item) => !interpolation.includes(item as string))) throw new Error(`SceneIR.lattices[${index}].interpolation is invalid`);
requireBoolean(lattice.useOutside, `lattices[${index}].useOutside`);
const activePoint = requireNumber(lattice.activePoint, `lattices[${index}].activePoint`);
if (!Number.isSafeInteger(activePoint) || activePoint < -1 || activePoint >= pointCount) throw new Error(`SceneIR.lattices[${index}].activePoint is invalid`);
requireString(lattice.vertexGroup, `lattices[${index}].vertexGroup`);
if (lattice.status !== "AVAILABLE" && lattice.status !== "BLOCKED") throw new Error(`SceneIR.lattices[${index}].status is invalid`);
if (lattice.status === "AVAILABLE") {
if (!Array.isArray(lattice.points) || lattice.points.length !== pointCount) throw new Error(`SceneIR.lattices[${index}].points is incomplete`);
for (const [pointIndex, point] of lattice.points.entries()) {
if (!isRecord(point)) throw new Error(`SceneIR.lattices[${index}].points[${pointIndex}] is invalid`);
requireTuple(point.coDeform, 3, `lattices[${index}].points[${pointIndex}].coDeform`);
const weight = requireNumber(point.weight, `lattices[${index}].points[${pointIndex}].weight`);
if (weight < 0.01 || weight > 100) throw new Error(`SceneIR.lattices[${index}].points[${pointIndex}].weight is invalid`);
requireBoolean(point.selected, `lattices[${index}].points[${pointIndex}].selected`);
}
if (lattice.errorCode !== undefined) throw new Error(`SceneIR.lattices[${index}] available data cannot contain errorCode`);
}
else if (!lattice.errorCode || lattice.points !== undefined) {
throw new Error(`SceneIR.lattices[${index}] blocked data is incomplete`);
}
}
}
if (value.nonMeshData !== undefined) requireArray(value.nonMeshData, "nonMeshData");
if (value.particleSettings !== undefined) {
const settings = requireArray(value.particleSettings, "particleSettings");
if (settings.length > 4096) throw new Error("SceneIR.particleSettings exceeds the data-block budget");
const ids = new Set<string>();
for (const [index, setting] of settings.entries()) {
if (!isRecord(setting)) throw new Error(`SceneIR.particleSettings[${index}] must be an object`);
const id = requireString(setting.id, `particleSettings[${index}].id`);
if (!id.startsWith("particle-settings:") || ids.has(id)) throw new Error(`SceneIR.particleSettings[${index}].id is invalid`);
ids.add(id);
requireString(setting.name, `particleSettings[${index}].name`);
for (const field of ["type", "from", "distribution", "physicsType", "totalParticles"] as const) {
const number = requireNumber(setting[field], `particleSettings[${index}].${field}`);
if (!Number.isSafeInteger(number) || number < 0) throw new Error(`SceneIR.particleSettings[${index}].${field} is invalid`);
}
for (const field of ["start", "end", "lifetime", "size", "drawSize"] as const) {
const number = requireNumber(setting[field], `particleSettings[${index}].${field}`);
if (number < 0 || number > 1_000_000) throw new Error(`SceneIR.particleSettings[${index}].${field} is invalid`);
}
}
}
if (value.sounds !== undefined) {
const sounds = requireArray(value.sounds, "sounds");
if (sounds.length > 4096) throw new Error("SceneIR.sounds exceeds the data-block budget");
const ids = new Set<string>();
for (const [index, sound] of sounds.entries()) {
if (!isRecord(sound)) throw new Error(`SceneIR.sounds[${index}] must be an object`);
const id = requireString(sound.id, `sounds[${index}].id`);
if (!id.startsWith("sound:") || ids.has(id)) throw new Error(`SceneIR.sounds[${index}].id is invalid`);
ids.add(id);
requireString(sound.name, `sounds[${index}].name`);
requireString(sound.sourcePath, `sounds[${index}].sourcePath`);
requireBoolean(sound.packed, `sounds[${index}].packed`);
for (const field of ["volume", "pitch"] as const) {
const number = requireNumber(sound[field], `sounds[${index}].${field}`);
if (number < 0 || number > 1000) throw new Error(`SceneIR.sounds[${index}].${field} is outside bounds`);
}
for (const field of ["audioChannels", "sampleRate"] as const) {
const number = requireNumber(sound[field], `sounds[${index}].${field}`);
if (!Number.isSafeInteger(number) || number < 0 || number > 1_000_000) throw new Error(`SceneIR.sounds[${index}].${field} is invalid`);
}
if (sound.packedByteLength !== undefined && (!Number.isSafeInteger(sound.packedByteLength) || (sound.packedByteLength as number) < 0)) throw new Error(`SceneIR.sounds[${index}].packedByteLength is invalid`);
if (sound.packed && sound.packedByteLength === undefined) throw new Error(`SceneIR.sounds[${index}] packed identity is incomplete`);
}
}
if (value.speakers !== undefined) {
const speakers = requireArray(value.speakers, "speakers");
if (speakers.length > 4096) throw new Error("SceneIR.speakers exceeds the data-block budget");
const ids = new Set<string>();
for (const [index, speaker] of speakers.entries()) {
if (!isRecord(speaker)) throw new Error(`SceneIR.speakers[${index}] must be an object`);
const id = requireString(speaker.id, `speakers[${index}].id`);
if (!id.startsWith("speaker:") || ids.has(id)) throw new Error(`SceneIR.speakers[${index}].id is invalid`);
ids.add(id);
requireString(speaker.name, `speakers[${index}].name`);
if (speaker.soundId !== undefined && (!Array.isArray(value.sounds) || typeof speaker.soundId !== "string" || !speaker.soundId.startsWith("sound:"))) {
throw new Error(`SceneIR.speakers[${index}].soundId is invalid`);
}
for (const field of ["volumeMax", "volumeMin", "distanceMax", "distanceReference", "attenuation"] as const) {
const number = requireNumber(speaker[field], `speakers[${index}].${field}`);
if (number < 0 || number > 1_000_000_000) throw new Error(`SceneIR.speakers[${index}].${field} is outside bounds`);
}
for (const field of ["coneAngleOuter", "coneAngleInner"] as const) {
const number = requireNumber(speaker[field], `speakers[${index}].${field}`);
if (number < 0 || number > 360) throw new Error(`SceneIR.speakers[${index}].${field} is outside bounds`);
}
const coneVolumeOuter = requireNumber(speaker.coneVolumeOuter, `speakers[${index}].coneVolumeOuter`);
if (coneVolumeOuter < 0 || coneVolumeOuter > 1) throw new Error(`SceneIR.speakers[${index}].coneVolumeOuter is outside bounds`);
for (const field of ["volume", "pitch"] as const) {
const number = requireNumber(speaker[field], `speakers[${index}].${field}`);
if (number < 0 || number > 1000) throw new Error(`SceneIR.speakers[${index}].${field} is outside bounds`);
}
}
}
if (value.texts !== undefined) {
const texts = requireArray(value.texts, "texts");
if (texts.length > 4096) throw new Error("SceneIR.texts exceeds the data-block budget");
const ids = new Set<string>();
for (const [index, text] of texts.entries()) {
if (!isRecord(text)) throw new Error(`SceneIR.texts[${index}] must be an object`);
const id = requireString(text.id, `texts[${index}].id`);
if (!id.startsWith("text:") || ids.has(id)) throw new Error(`SceneIR.texts[${index}].id is invalid`);
ids.add(id);
requireString(text.name, `texts[${index}].name`);
const source = requireString(text.source, `texts[${index}].source`);
const sourceSha256 = requireString(text.sourceSha256, `texts[${index}].sourceSha256`);
if (!/^[a-f0-9]{64}$/.test(sourceSha256)) throw new Error(`SceneIR.texts[${index}].sourceSha256 is invalid`);
const byteLength = requireNumber(text.byteLength, `texts[${index}].byteLength`);
const lineCount = requireNumber(text.lineCount, `texts[${index}].lineCount`);
if (!Number.isSafeInteger(byteLength) || byteLength !== new TextEncoder().encode(source).byteLength || byteLength > 1_048_576) throw new Error(`SceneIR.texts[${index}].byteLength is invalid`);
if (!Number.isSafeInteger(lineCount) || lineCount < 1 || lineCount > 65_536) throw new Error(`SceneIR.texts[${index}].lineCount is invalid`);
requireBoolean(text.internal, `texts[${index}].internal`);
if (text.sourcePath !== undefined) requireString(text.sourcePath, `texts[${index}].sourcePath`);
requireBoolean(text.isDirty, `texts[${index}].isDirty`);
requireBoolean(text.useModule, `texts[${index}].useModule`);
}
}
if (value.textures !== undefined) {
const textures = requireArray(value.textures, "textures");
if (textures.length > 4096) throw new Error("SceneIR.textures exceeds the data-block budget");
const ids = new Set<string>();
for (const [index, texture] of textures.entries()) {
if (!isRecord(texture)) throw new Error(`SceneIR.textures[${index}] must be an object`);
const id = requireString(texture.id, `textures[${index}].id`);
if (!id.startsWith("texture:") || ids.has(id)) throw new Error(`SceneIR.textures[${index}].id is invalid`);
ids.add(id);
requireString(texture.name, `textures[${index}].name`);
const type = requireNumber(texture.type, `textures[${index}].type`);
const noiseDepth = requireNumber(texture.noiseDepth, `textures[${index}].noiseDepth`);
if (!Number.isSafeInteger(type) || type < 0 || !Number.isSafeInteger(noiseDepth) || noiseDepth < 0 || noiseDepth > 30) throw new Error(`SceneIR.textures[${index}] integer fields are invalid`);
for (const field of ["noiseScale", "intensity", "contrast", "saturation"] as const) {
const number = requireNumber(texture[field], `textures[${index}].${field}`);
if (number < 0 || number > 1000) throw new Error(`SceneIR.textures[${index}].${field} is outside bounds`);
}
}
}
if (value.windowManager !== undefined) {
if (!isRecord(value.windowManager)) throw new Error("SceneIR.windowManager must be an object");
const manager = value.windowManager;
const id = requireString(manager.id, "windowManager.id");
if (id !== "window-manager:WinMan") throw new Error("SceneIR.windowManager.id is invalid");
requireString(manager.name, "windowManager.name");
requireString(manager.presetName, "windowManager.presetName");
const windowCount = requireNumber(manager.windowCount, "windowManager.windowCount");
if (!Number.isSafeInteger(windowCount) || windowCount < 0 || windowCount > 1024) throw new Error("SceneIR.windowManager.windowCount is invalid");
requireBoolean(manager.interfaceLocked, "windowManager.interfaceLocked");
}
if (value.workspaces !== undefined) {
const workspaces = requireArray(value.workspaces, "workspaces");
const ids = new Set<string>();
for (const [index, workspace] of workspaces.entries()) {
if (!isRecord(workspace)) throw new Error(`SceneIR.workspaces[${index}] must be an object`);
const id = requireString(workspace.id, `workspaces[${index}].id`);
if (!id.startsWith("workspace:") || ids.has(id)) throw new Error(`SceneIR.workspaces[${index}].id is invalid`); ids.add(id);
requireString(workspace.name, `workspaces[${index}].name`); const count = requireNumber(workspace.screenCount, `workspaces[${index}].screenCount`);
if (!Number.isSafeInteger(count) || count < 0 || count > 64) throw new Error(`SceneIR.workspaces[${index}].screenCount is invalid`);
}
}
if (value.greasePencils !== undefined) requireArray(value.greasePencils, "greasePencils");
if (value.libraries !== undefined) requireArray(value.libraries, "libraries");
requireArray(value.animations, "animations");

View File

@@ -0,0 +1,47 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildTaskContext } from "../../../tools/web/task-context-lib.mjs";
import { validateContextBundle } from "../../../tools/web/context-governance.mjs";
test("current context satisfies document, task-card, and pointer budgets", () => {
const bundle = buildTaskContext();
const result = validateContextBundle(bundle);
assert.deepEqual(result.violations, []);
assert.ok(result.report.totalTokens <= 3500);
});
test("task cards cannot smuggle full plans or history into the default context", () => {
const bundle = buildTaskContext();
const taskSource = `${bundle.sources.taskSource}\nnext-task-plan.json\n`;
const result = validateContextBundle({
...bundle,
sources: { ...bundle.sources, taskSource },
});
assert.ok(result.violations.some(({ code }) => code === "FORBIDDEN_CONTEXT_REFERENCE"));
});
test("governance rejects forged selection totals and unaudited exclusions", () => {
const bundle = buildTaskContext();
const forged = {
...bundle,
context: {
...bundle.context,
inputPaths: ["tools/web/generated/M16-GAP-00022.py"],
inputSelection: {
...bundle.context.inputSelection,
selected: [{ path: "tools/web/generated/M16-GAP-00022.py", bytes: 9000, tokens: 1 }],
excluded: [
{ path: "tests/files/web/generated/M16-GAP-00022-datablock-Sound.blend", reason: "FORGED_REASON" },
{ path: "tests/golden/M16-GAP-00022/" },
],
totals: { files: 1, bytes: 9000, tokens: 2250 },
},
},
};
const result = validateContextBundle(forged);
assert.ok(result.violations.some(({ code }) => code === "EVIDENCE_BYTES_OVER_BUDGET"));
assert.ok(result.violations.some(({ code }) => code === "CONTEXT_REMAINING_BYTES_OVER_BUDGET"));
assert.ok(result.violations.some(({ code }) => code === "INPUT_EXCLUSION_REASON_MISSING"));
assert.ok(result.violations.some(({ code }) => code === "INPUT_EXCLUSION_REASON_UNKNOWN"));
assert.ok(result.violations.some(({ code }) => code === "INPUT_SELECTION_ENTRY_INVALID"));
});

View File

@@ -25,3 +25,13 @@ test("M14-04F commits pen stroke once and ignores late up/cancel", () => {
state = modal.commitPenStroke(state, 9);
assert.deepEqual([state.kind, state.mainCommitCount, state.activePointerIds], ["NONE", 1, []]);
});
test("M14-04F reduces production pointer events and rejects invalid IDs", () => {
let state = modal.createInputModalState();
state = modal.reduceInputModal(state, { type: "pointerdown", pointerType: "touch", pointerId: 2 });
state = modal.reduceInputModal(state, { type: "pointerdown", pointerType: "touch", pointerId: 4 });
assert.equal(state.navigationRevision, 1);
state = modal.reduceInputModal(state, { type: "pointercancel", pointerType: "touch", pointerId: 2 });
assert.deepEqual([state.kind, state.activePointerIds, state.cancelled], ["NONE", [], true]);
assert.throws(() => modal.reduceInputModal(state, { type: "pointerdown", pointerType: "pen", pointerId: -1 }), /POINTER_ID_INVALID/);
});

View File

@@ -0,0 +1,72 @@
import test from "node:test";
import assert from "node:assert/strict";
import { buildTaskContext, contextSizeReport, CONTEXT_LIMITS, readIndexedTask, selectInputPaths } from "../../../tools/web/task-context-lib.mjs";
test("current task context is bounded and follows the parent pointer", () => {
const bundle = buildTaskContext();
const report = contextSizeReport(bundle);
assert.match(bundle.context.task, /^M\d+-GAP-\d{5}$/);
assert.match(bundle.context.parentTask, /^M\d+-GAP-\d{5}$/);
assert.equal(bundle.context.parent.manifest.nextTask, bundle.context.task);
assert.ok(bundle.context.commands.length >= 1);
assert.equal(bundle.context.sourceDocuments.taskCard, `docs/tasks/${bundle.context.task}.md`);
assert.equal(bundle.context.sourceDocuments.taskIndex, "tests/golden/M15-03A/task-index.json");
assert.ok(!JSON.stringify(bundle.context).includes("next-task-plan.json"));
assert.equal(bundle.context.scope.ownerFamily, readIndexedTask(bundle.context.task).ownerFamily);
assert.equal(report.withinBudget, true);
assert.equal(report.totalTokens, report.sourceTokens + report.evidenceTokens);
assert.equal(report.evidenceTokens, bundle.context.inputSelection.totals.tokens);
assert.ok(report.totalTokens < CONTEXT_LIMITS.contextTokens);
});
test("the indexed task record remains available beside the compact card", () => {
const bundle = buildTaskContext("M16-GAP-00011");
assert.equal(bundle.context.sourceDocuments.taskCard, "docs/tasks/M16-GAP-00011.md");
assert.equal(bundle.context.parent.manifest.status, "done");
assert.equal(bundle.context.nextTask, "M16-GAP-00012");
});
test("input selection excludes evidence before it can enter the read list", () => {
const fixture = "tests/files/web/generated/M16-GAP-00022-datablock-Sound.blend";
const generator = "tools/web/generated/M16-GAP-00022.py";
const result = selectInputPaths([fixture, generator], {
sourceBytes: 100,
sourceTokens: 25,
limits: { ...CONTEXT_LIMITS, evidenceBytes: 1024 },
});
assert.deepEqual(result.inputPaths, [generator]);
assert.equal(result.inputSelection.excluded[0].reason, "EVIDENCE_BYTE_BUDGET");
assert.equal(result.inputSelection.totals.bytes, result.inputSelection.selected[0].bytes);
});
test("input selection audits remaining context and file-count exclusions", () => {
const paths = [
"tools/web/generated/M16-GAP-00022.py",
"tools/web/generated/M16-GAP-00021.py",
];
const remaining = selectInputPaths(paths, {
sourceBytes: 0,
sourceTokens: CONTEXT_LIMITS.contextTokens - 1,
limits: { ...CONTEXT_LIMITS, evidenceBytes: 8192 },
});
assert.deepEqual(remaining.inputPaths, []);
assert.equal(remaining.inputSelection.excluded[0].reason, "CONTEXT_REMAINING_SPACE");
const count = selectInputPaths(paths, {
limits: { ...CONTEXT_LIMITS, evidenceFiles: 1, evidenceBytes: 8192 },
});
assert.equal(count.inputPaths.length, 1);
assert.equal(count.inputSelection.excluded[0].reason, "EVIDENCE_FILE_COUNT");
});
test("generated task outputs are excluded before their size can affect selection", () => {
const result = selectInputPaths(["tests/golden/M16-GAP-00022/"], {
generatedPaths: ["tests/golden/M16-GAP-00022/"],
});
assert.deepEqual(result.inputPaths, []);
assert.deepEqual(result.inputSelection.excluded, [{
path: "tests/golden/M16-GAP-00022/",
bytes: null,
reason: "GENERATED_EVIDENCE_OUTPUT",
}]);
});