完善资源画布无限坐标与分页交互
调整资源分页画布 viewport 与复位按钮交互 允许资源元素使用 -1,000,000 到 1,000,000 的持久化坐标 同步 TypeScript、Rust 合同、布局校验与负坐标回归测试 更新资源画布 PRD 与技术方案 保留本地 .env 未提交
This commit is contained in:
@@ -4378,6 +4378,17 @@ fn recover_asset_canvas_transaction_locked(
|
||||
event,
|
||||
));
|
||||
}
|
||||
if find_superseding_asset_canvas_commit(
|
||||
root,
|
||||
&journal,
|
||||
¤t_manifest,
|
||||
¤t_manifest_sha,
|
||||
¤t_revision,
|
||||
¤t_revision_sha,
|
||||
)? {
|
||||
return mark_asset_canvas_superseded_locked(root, journal, ledger)
|
||||
.map(|outcome| (outcome, None));
|
||||
}
|
||||
return mark_asset_canvas_reconciliation_locked(root, journal, ledger)
|
||||
.map(|outcome| (outcome, None));
|
||||
}
|
||||
|
||||
@@ -1060,6 +1060,65 @@ fn recovery_marks_interrupted_refine_commit_superseded_by_verified_later_commit(
|
||||
commit_asset_canvas_at(fixture.root(), &third_input).expect("commit after superseded recovery");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_marks_completed_refine_commit_superseded_by_verified_later_commit() {
|
||||
let (fixture, first_commit_id, second_commit_id) =
|
||||
initialize_refine_fixture_with_later_commit();
|
||||
// initialize_refine_fixture_with_later_commit only forces the first
|
||||
// transaction into the unresolved state. Restore both completed ledgers so
|
||||
// this test models two successful commits followed by a restart.
|
||||
for commit_id in [&first_commit_id, &second_commit_id] {
|
||||
let mut journal = read_asset_canvas_journal(fixture.root(), commit_id)
|
||||
.expect("read completed transaction journal")
|
||||
.expect("completed transaction journal");
|
||||
let mut ledger = read_asset_canvas_ledger(fixture.root(), commit_id)
|
||||
.expect("read completed transaction ledger")
|
||||
.expect("completed transaction ledger");
|
||||
journal.stage = AssetCanvasTransactionStage::Committed;
|
||||
ledger.status = AssetCanvasLedgerStatus::Committed;
|
||||
write_asset_canvas_journal(fixture.root(), &journal)
|
||||
.expect("restore completed journal stage");
|
||||
write_asset_canvas_ledger(fixture.root(), &ledger)
|
||||
.expect("restore completed ledger status");
|
||||
}
|
||||
|
||||
let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
|
||||
.expect("recover two completed refine transactions");
|
||||
let first_outcome = recovered
|
||||
.result
|
||||
.outcomes
|
||||
.iter()
|
||||
.find(|outcome| outcome.commit_id == first_commit_id)
|
||||
.expect("first completed outcome");
|
||||
let second_outcome = recovered
|
||||
.result
|
||||
.outcomes
|
||||
.iter()
|
||||
.find(|outcome| outcome.commit_id == second_commit_id)
|
||||
.expect("second completed outcome");
|
||||
assert_eq!(
|
||||
first_outcome.status,
|
||||
RecoverAssetCanvasOutcomeStatus::Superseded
|
||||
);
|
||||
assert_eq!(
|
||||
second_outcome.status,
|
||||
RecoverAssetCanvasOutcomeStatus::AlreadyCommitted
|
||||
);
|
||||
assert!(!recovered.result.outcomes.iter().any(|outcome| {
|
||||
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
|
||||
}));
|
||||
|
||||
let repeated = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
|
||||
.expect("repeat recovery after supersede");
|
||||
assert!(repeated.result.outcomes.iter().any(|outcome| {
|
||||
outcome.commit_id == first_commit_id
|
||||
&& outcome.status == RecoverAssetCanvasOutcomeStatus::Superseded
|
||||
}));
|
||||
assert!(!repeated.result.outcomes.iter().any(|outcome| {
|
||||
outcome.status == RecoverAssetCanvasOutcomeStatus::ReconciliationRequired
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_does_not_supersede_when_later_transaction_identity_is_broken() {
|
||||
let (fixture, first_commit_id, second_commit_id) =
|
||||
|
||||
@@ -12,7 +12,8 @@ const RESOURCE_LAYOUT_LOCK_PATH: &str = ".agent/workbench/resource-layouts/.layo
|
||||
const RESOURCE_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024;
|
||||
const RESOURCE_LAYOUT_MAX_POSITIONS: usize = 4096;
|
||||
const RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS: usize = 512;
|
||||
const RESOURCE_LAYOUT_MAX_COORDINATE: u32 = 1_000_000;
|
||||
const RESOURCE_LAYOUT_MIN_COORDINATE: i32 = -1_000_000;
|
||||
const RESOURCE_LAYOUT_MAX_COORDINATE: i32 = 1_000_000;
|
||||
const RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS: usize = 100;
|
||||
const RESOURCE_LAYOUT_LOCK_WAIT_MILLIS: u64 = 10;
|
||||
|
||||
@@ -337,11 +338,13 @@ fn validate_resource_layout_positions(
|
||||
if !resource_ids.insert(resource_id) {
|
||||
return Err("资源布局 resourceId 不能重复".to_string());
|
||||
}
|
||||
if position.x > RESOURCE_LAYOUT_MAX_COORDINATE
|
||||
if position.x < RESOURCE_LAYOUT_MIN_COORDINATE
|
||||
|| position.x > RESOURCE_LAYOUT_MAX_COORDINATE
|
||||
|| position.y < RESOURCE_LAYOUT_MIN_COORDINATE
|
||||
|| position.y > RESOURCE_LAYOUT_MAX_COORDINATE
|
||||
{
|
||||
return Err(format!(
|
||||
"资源布局坐标不能超过 {RESOURCE_LAYOUT_MAX_COORDINATE}"
|
||||
"资源布局坐标必须在 {RESOURCE_LAYOUT_MIN_COORDINATE} 到 {RESOURCE_LAYOUT_MAX_COORDINATE} 之间"
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -526,7 +529,7 @@ mod tests {
|
||||
))
|
||||
}
|
||||
|
||||
fn layout_position(id: &str, x: u32) -> ProjectResourceCanvasPosition {
|
||||
fn layout_position(id: &str, x: i32) -> ProjectResourceCanvasPosition {
|
||||
ProjectResourceCanvasPosition {
|
||||
resource_id: id.to_string(),
|
||||
section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Art,
|
||||
@@ -883,11 +886,27 @@ mod tests {
|
||||
RESOURCE_LAYOUT_MAX_COORDINATE
|
||||
);
|
||||
|
||||
let coordinate_error = update_project_resource_canvas_layout_at(
|
||||
let lower_boundary = update_project_resource_canvas_layout_at(
|
||||
&root,
|
||||
ProjectResourceCanvasLayoutMode::Type,
|
||||
"layout-invalid",
|
||||
boundary.layout.revision,
|
||||
vec![layout_position(
|
||||
"asset-coordinate-lower-boundary",
|
||||
RESOURCE_LAYOUT_MIN_COORDINATE,
|
||||
)],
|
||||
)
|
||||
.expect("lower boundary coordinate must remain valid");
|
||||
assert_eq!(
|
||||
lower_boundary.layout.positions[0].x,
|
||||
RESOURCE_LAYOUT_MIN_COORDINATE
|
||||
);
|
||||
|
||||
let coordinate_error = update_project_resource_canvas_layout_at(
|
||||
&root,
|
||||
ProjectResourceCanvasLayoutMode::Type,
|
||||
"layout-invalid",
|
||||
lower_boundary.layout.revision,
|
||||
vec![layout_position(
|
||||
"asset-coordinate",
|
||||
RESOURCE_LAYOUT_MAX_COORDINATE + 1,
|
||||
@@ -895,6 +914,18 @@ mod tests {
|
||||
)
|
||||
.expect_err("oversized coordinate must fail");
|
||||
assert!(coordinate_error.contains("坐标"));
|
||||
let lower_coordinate_error = update_project_resource_canvas_layout_at(
|
||||
&root,
|
||||
ProjectResourceCanvasLayoutMode::Type,
|
||||
"layout-invalid",
|
||||
lower_boundary.layout.revision,
|
||||
vec![layout_position(
|
||||
"asset-coordinate-below-lower-boundary",
|
||||
RESOURCE_LAYOUT_MIN_COORDINATE - 1,
|
||||
)],
|
||||
)
|
||||
.expect_err("coordinate below lower boundary must fail");
|
||||
assert!(lower_coordinate_error.contains("坐标"));
|
||||
let id_error = update_project_resource_canvas_layout_at(
|
||||
&root,
|
||||
ProjectResourceCanvasLayoutMode::Type,
|
||||
|
||||
@@ -4878,6 +4878,11 @@ iframe.preview-frame {
|
||||
box-shadow: var(--platform-panel-shadow);
|
||||
}
|
||||
|
||||
.game-workbench-stage > .game-resource-manager {
|
||||
grid-row: 3;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-workbench-chat {
|
||||
border-color: var(--platform-line-soft);
|
||||
box-shadow: var(--platform-nav-active-shadow);
|
||||
@@ -5005,6 +5010,7 @@ iframe.preview-frame {
|
||||
.game-resource-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -5081,6 +5087,8 @@ iframe.preview-frame {
|
||||
.game-resource-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
@@ -5101,6 +5109,17 @@ iframe.preview-frame {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.game-resource-canvas--paged {
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
overscroll-behavior: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.game-resource-canvas--paged:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.game-resource-canvas-content {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -5116,6 +5135,190 @@ iframe.preview-frame {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-resource-canvas-content--paged {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-resource-page-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 212px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.game-resource-outline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 0;
|
||||
padding: 12px 2px;
|
||||
overflow: auto;
|
||||
border-right: 1px solid #eaded8;
|
||||
}
|
||||
|
||||
.game-resource-outline button {
|
||||
display: grid;
|
||||
grid-template-columns: 18px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 34px;
|
||||
padding: 0 9px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #725348;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.game-resource-outline button:hover,
|
||||
.game-resource-outline button:focus-visible,
|
||||
.game-resource-outline button.is-active {
|
||||
background: #f6ece6;
|
||||
color: #a9502a;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.game-resource-outline button strong {
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.game-resource-outline button small {
|
||||
color: #a98a7c;
|
||||
font-size: 10px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.game-resource-page {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.game-resource-page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 36px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.game-resource-page-heading span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #6d4b3f;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.game-resource-page-heading strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.game-resource-page-heading small {
|
||||
color: #a98a7c;
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.game-resource-page-canvas {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid #ecdfd8;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
radial-gradient(#eaded8 0.8px, transparent 0.8px) 0 0 / 18px 18px,
|
||||
#fffdfa;
|
||||
}
|
||||
|
||||
.game-resource-canvas-reset {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 3;
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid rgb(227 207 197 / 88%);
|
||||
border-radius: 8px;
|
||||
background: rgb(255 253 250 / 88%);
|
||||
color: #8c6252;
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
box-shadow: 0 4px 12px rgb(112 70 52 / 10%);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.game-resource-canvas-reset:hover,
|
||||
.game-resource-canvas-reset:focus-visible {
|
||||
border-color: #ce7650;
|
||||
color: #bd5f37;
|
||||
outline: 2px solid rgb(206 118 80 / 22%);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.game-resource-page-world {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.game-resource-page-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
padding: 24px;
|
||||
color: #b0958a;
|
||||
font-size: 12px;
|
||||
place-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.game-resource-next-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #e8d6cc;
|
||||
border-radius: 10px;
|
||||
background: #fff8f4;
|
||||
color: #725348;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.game-resource-next-page:hover,
|
||||
.game-resource-next-page:focus-visible {
|
||||
border-color: #d6946f;
|
||||
color: #a9502a;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.game-resource-next-page span {
|
||||
color: #a98a7c;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.game-resource-next-page strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.game-resource-section-stack {
|
||||
display: contents;
|
||||
}
|
||||
@@ -5374,6 +5577,18 @@ iframe.preview-frame {
|
||||
transform: translate3d(var(--resource-x, 0), var(--resource-y, 0), 0);
|
||||
}
|
||||
|
||||
.game-resource-card {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.game-resource-card.is-dragging {
|
||||
cursor: grabbing;
|
||||
box-shadow:
|
||||
0 14px 32px rgb(195 105 62 / 24%),
|
||||
0 0 0 2px rgb(216 115 66 / 24%);
|
||||
}
|
||||
|
||||
.game-resource-card:hover,
|
||||
.game-resource-card:focus-within,
|
||||
.game-resource-card.is-selected {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+33
-82
@@ -6,6 +6,7 @@ import {
|
||||
|
||||
import {
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
|
||||
GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
||||
type ProjectResourceCanvasLayout,
|
||||
type ProjectResourceCanvasLayoutMode,
|
||||
@@ -29,15 +30,14 @@ export const RESOURCE_CANVAS_TYPE_COLUMNS = 3;
|
||||
export const RESOURCE_CANVAS_SECTION_MIN_WIDTH = 620;
|
||||
export const RESOURCE_CANVAS_SECTION_MIN_HEIGHT = 108;
|
||||
export const RESOURCE_CANVAS_CLUSTER_GAP = 48;
|
||||
export const RESOURCE_CANVAS_BOUNDARY_PADDING = 96;
|
||||
/**
|
||||
* Initial fit inset only. The resource canvas background itself is infinite;
|
||||
* this value must not be used to clamp a later pan or zoom.
|
||||
*/
|
||||
export const RESOURCE_CANVAS_FIT_PADDING = 96;
|
||||
|
||||
const sectionOrder: ProjectResourceCanvasSection[] = [
|
||||
'code',
|
||||
'document',
|
||||
'version',
|
||||
'art',
|
||||
'audio',
|
||||
];
|
||||
export const RESOURCE_CANVAS_SECTION_ORDER: readonly ProjectResourceCanvasSection[] =
|
||||
['document', 'art', 'audio', 'code', 'version'];
|
||||
|
||||
export type ResourceCanvasCardSize = {
|
||||
width: number;
|
||||
@@ -65,76 +65,24 @@ function clampResourceCanvasNumber(
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the dependency canvas inside the invisible boundary formed by every
|
||||
* resource plus a fixed screen-space safety margin. The minimum zoom is the
|
||||
* scale at which the whole boundary fits; panning never lets the resource
|
||||
* union move beyond the safety margin on either axis.
|
||||
* Normalizes an infinite resource-canvas viewport.
|
||||
*
|
||||
* The canvas has no navigational edge: x/y are intentionally left untouched
|
||||
* so users can pan the background and resources arbitrarily far in any
|
||||
* direction. Only invalid coordinates and the shared image-canvas scale
|
||||
* limits are normalized here.
|
||||
*/
|
||||
export function constrainResourceCanvasViewport({
|
||||
bounds,
|
||||
canvasSize,
|
||||
padding = RESOURCE_CANVAS_BOUNDARY_PADDING,
|
||||
viewport,
|
||||
}: {
|
||||
bounds: ResourceCanvasNavigationBounds;
|
||||
canvasSize: { width: number; height: number };
|
||||
padding?: number;
|
||||
viewport: CanvasViewport;
|
||||
}): CanvasViewport {
|
||||
const canvasWidth = Math.max(
|
||||
1,
|
||||
Number.isFinite(canvasSize.width) ? canvasSize.width : 1,
|
||||
);
|
||||
const canvasHeight = Math.max(
|
||||
1,
|
||||
Number.isFinite(canvasSize.height) ? canvasSize.height : 1,
|
||||
);
|
||||
const boundsX = Number.isFinite(bounds.x) ? bounds.x : 0;
|
||||
const boundsY = Number.isFinite(bounds.y) ? bounds.y : 0;
|
||||
const boundsWidth = Math.max(
|
||||
1,
|
||||
Number.isFinite(bounds.width) ? bounds.width : 1,
|
||||
);
|
||||
const boundsHeight = Math.max(
|
||||
1,
|
||||
Number.isFinite(bounds.height) ? bounds.height : 1,
|
||||
);
|
||||
const normalizedPadding = Math.max(0, Number.isFinite(padding) ? padding : 0);
|
||||
const horizontalPadding = Math.min(normalizedPadding, (canvasWidth - 1) / 2);
|
||||
const verticalPadding = Math.min(normalizedPadding, (canvasHeight - 1) / 2);
|
||||
const availableWidth = Math.max(1, canvasWidth - horizontalPadding * 2);
|
||||
const availableHeight = Math.max(1, canvasHeight - verticalPadding * 2);
|
||||
const minimumScale = clampResourceCanvasNumber(
|
||||
Math.min(1, availableWidth / boundsWidth, availableHeight / boundsHeight),
|
||||
MIN_SCALE,
|
||||
MAX_SCALE,
|
||||
);
|
||||
const scale = clampResourceCanvasNumber(
|
||||
Number.isFinite(viewport.scale) ? viewport.scale : minimumScale,
|
||||
minimumScale,
|
||||
MAX_SCALE,
|
||||
);
|
||||
const minimumX =
|
||||
canvasWidth - horizontalPadding - (boundsX + boundsWidth) * scale;
|
||||
const maximumX = horizontalPadding - boundsX * scale;
|
||||
const minimumY =
|
||||
canvasHeight - verticalPadding - (boundsY + boundsHeight) * scale;
|
||||
const maximumY = verticalPadding - boundsY * scale;
|
||||
const centeredX = canvasWidth / 2 - (boundsX + boundsWidth / 2) * scale;
|
||||
const centeredY = canvasHeight / 2 - (boundsY + boundsHeight / 2) * scale;
|
||||
const candidateX = Number.isFinite(viewport.x) ? viewport.x : centeredX;
|
||||
const candidateY = Number.isFinite(viewport.y) ? viewport.y : centeredY;
|
||||
|
||||
export function normalizeInfiniteResourceCanvasViewport(
|
||||
viewport: CanvasViewport,
|
||||
): CanvasViewport {
|
||||
return {
|
||||
x:
|
||||
minimumX <= maximumX
|
||||
? clampResourceCanvasNumber(candidateX, minimumX, maximumX)
|
||||
: centeredX,
|
||||
y:
|
||||
minimumY <= maximumY
|
||||
? clampResourceCanvasNumber(candidateY, minimumY, maximumY)
|
||||
: centeredY,
|
||||
scale,
|
||||
x: Number.isFinite(viewport.x) ? viewport.x : 0,
|
||||
y: Number.isFinite(viewport.y) ? viewport.y : 0,
|
||||
scale: clampResourceCanvasNumber(
|
||||
Number.isFinite(viewport.scale) ? viewport.scale : 1,
|
||||
MIN_SCALE,
|
||||
MAX_SCALE,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1185,7 +1133,7 @@ export function reconcileResourceCanvasLayout(
|
||||
resources.map((resource) => [resource.id, resource]),
|
||||
);
|
||||
const positionsBySection = new Map(
|
||||
sectionOrder.map((section) => [
|
||||
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
|
||||
section,
|
||||
[] as ProjectResourceCanvasPosition[],
|
||||
]),
|
||||
@@ -1202,7 +1150,10 @@ export function reconcileResourceCanvasLayout(
|
||||
preserved.map((position) => position.resourceId),
|
||||
);
|
||||
const newResourcesBySection = new Map(
|
||||
sectionOrder.map((section) => [section, [] as ResourceCanvasItem[]]),
|
||||
RESOURCE_CANVAS_SECTION_ORDER.map((section) => [
|
||||
section,
|
||||
[] as ResourceCanvasItem[],
|
||||
]),
|
||||
);
|
||||
for (const resource of resources) {
|
||||
if (!preservedIds.has(resource.id)) {
|
||||
@@ -1210,7 +1161,7 @@ export function reconcileResourceCanvasLayout(
|
||||
}
|
||||
}
|
||||
|
||||
for (const section of sectionOrder) {
|
||||
for (const section of RESOURCE_CANVAS_SECTION_ORDER) {
|
||||
const sectionPositions = positionsBySection.get(section) ?? [];
|
||||
const sectionResources = resources.filter(
|
||||
(resource) => resource.category === section,
|
||||
@@ -1255,7 +1206,7 @@ export function reconcileResourceCanvasLayout(
|
||||
}
|
||||
}
|
||||
|
||||
const ordered = sectionOrder.flatMap((section) =>
|
||||
const ordered = RESOURCE_CANVAS_SECTION_ORDER.flatMap((section) =>
|
||||
(positionsBySection.get(section) ?? []).sort(
|
||||
(left, right) =>
|
||||
left.y - right.y ||
|
||||
@@ -1283,11 +1234,11 @@ export function moveResourceCanvasPosition(
|
||||
const finiteY = Number.isFinite(y) ? y : 0;
|
||||
const normalizedX = Math.min(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||||
Math.max(0, Math.round(finiteX)),
|
||||
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteX)),
|
||||
);
|
||||
const normalizedY = Math.min(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||||
Math.max(0, Math.round(finiteY)),
|
||||
Math.max(GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE, Math.round(finiteY)),
|
||||
);
|
||||
return {
|
||||
...layout,
|
||||
|
||||
+2
-7
@@ -10,6 +10,7 @@ import type {
|
||||
ProjectResourceCanvasLayoutMode,
|
||||
ProjectResourceCanvasSection,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { RESOURCE_CANVAS_SECTION_ORDER } from './resourceCanvasLayoutModel';
|
||||
import {
|
||||
clampProjectResourceSectionHeight,
|
||||
clampProjectResourceSectionZoom,
|
||||
@@ -29,13 +30,7 @@ const RESOURCE_LAYOUT_MODES: readonly ProjectResourceCanvasLayoutMode[] = [
|
||||
'dependency',
|
||||
'type',
|
||||
];
|
||||
const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [
|
||||
'code',
|
||||
'document',
|
||||
'version',
|
||||
'art',
|
||||
'audio',
|
||||
];
|
||||
const RESOURCE_SECTIONS = RESOURCE_CANVAS_SECTION_ORDER;
|
||||
|
||||
export type ProjectResourceSectionHeightState = {
|
||||
height: number;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,14 +2,15 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE,
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
|
||||
isSafeProjectResourceCanvasCoordinate,
|
||||
} from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
constrainResourceCanvasViewport,
|
||||
createEmptyResourceCanvasLayout,
|
||||
createResourceCanvasCardSizeByResourceId,
|
||||
DEFAULT_RESOURCE_CANVAS_CARD_SIZE,
|
||||
moveResourceCanvasPosition,
|
||||
normalizeInfiniteResourceCanvasViewport,
|
||||
reconcileResourceCanvasLayout,
|
||||
RESOURCE_CANVAS_CARD_HEIGHT,
|
||||
RESOURCE_CANVAS_CARD_WIDTH,
|
||||
@@ -57,44 +58,42 @@ function positionById(
|
||||
}
|
||||
|
||||
describe('resource canvas layout model', () => {
|
||||
it('clamps dependency-canvas panning to the resource union and safety margin', () => {
|
||||
const common = {
|
||||
bounds: { x: 0, y: 0, width: 1_000, height: 800 },
|
||||
canvasSize: { width: 800, height: 600 },
|
||||
};
|
||||
|
||||
it('allows infinite background panning while keeping zoom within shared limits', () => {
|
||||
expect(
|
||||
constrainResourceCanvasViewport({
|
||||
...common,
|
||||
viewport: { x: 2_000, y: 2_000, scale: 1 },
|
||||
normalizeInfiniteResourceCanvasViewport({
|
||||
x: 2_000,
|
||||
y: 2_000,
|
||||
scale: 1,
|
||||
}),
|
||||
).toEqual({ x: 96, y: 96, scale: 1 });
|
||||
).toEqual({ x: 2_000, y: 2_000, scale: 1 });
|
||||
expect(
|
||||
constrainResourceCanvasViewport({
|
||||
...common,
|
||||
viewport: { x: -2_000, y: -2_000, scale: 1 },
|
||||
normalizeInfiniteResourceCanvasViewport({
|
||||
x: -2_000,
|
||||
y: -2_000,
|
||||
scale: 0.001,
|
||||
}),
|
||||
).toEqual({ x: -296, y: -296, scale: 1 });
|
||||
).toEqual({ x: -2_000, y: -2_000, scale: 0.025 });
|
||||
});
|
||||
|
||||
it('stops zooming out once all resources and the hidden margin fit', () => {
|
||||
expect(
|
||||
constrainResourceCanvasViewport({
|
||||
bounds: { x: 0, y: 0, width: 1_000, height: 800 },
|
||||
canvasSize: { width: 800, height: 600 },
|
||||
viewport: { x: -800, y: 900, scale: 0.001 },
|
||||
}),
|
||||
).toEqual({ x: 145, y: 96, scale: 0.51 });
|
||||
it('does not pull the viewport back when resource extents change', () => {
|
||||
const before = normalizeInfiniteResourceCanvasViewport({
|
||||
x: 12_000,
|
||||
y: -9_000,
|
||||
scale: 0.75,
|
||||
});
|
||||
const after = normalizeInfiniteResourceCanvasViewport(before);
|
||||
|
||||
expect(after).toEqual(before);
|
||||
});
|
||||
|
||||
it('centers a resource boundary smaller than the viewport instead of allowing blank panning', () => {
|
||||
it('repairs invalid viewport coordinates without adding a canvas boundary', () => {
|
||||
expect(
|
||||
constrainResourceCanvasViewport({
|
||||
bounds: { x: 20, y: 30, width: 200, height: 100 },
|
||||
canvasSize: { width: 800, height: 600 },
|
||||
viewport: { x: -5_000, y: 5_000, scale: 1 },
|
||||
normalizeInfiniteResourceCanvasViewport({
|
||||
x: Number.NaN,
|
||||
y: Number.POSITIVE_INFINITY,
|
||||
scale: Number.NaN,
|
||||
}),
|
||||
).toEqual({ x: 280, y: 220, scale: 1 });
|
||||
).toEqual({ x: 0, y: 0, scale: 1 });
|
||||
});
|
||||
|
||||
it('creates non-overlapping defaults and keeps the two modes independent', () => {
|
||||
@@ -179,6 +178,32 @@ describe('resource canvas layout model', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves negative manual positions within the symmetric canvas range', () => {
|
||||
const base = reconcileResourceCanvasLayout(
|
||||
createEmptyResourceCanvasLayout('project-negative', 'type'),
|
||||
[resource('a', 'document')],
|
||||
).layout;
|
||||
const moved = moveResourceCanvasPosition(
|
||||
base,
|
||||
'a',
|
||||
'document',
|
||||
-123.4,
|
||||
-45.6,
|
||||
);
|
||||
|
||||
expect(moved.positions[0]).toMatchObject({ x: -123, y: -46 });
|
||||
expect(
|
||||
isSafeProjectResourceCanvasCoordinate(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSafeProjectResourceCanvasCoordinate(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE - 1,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('drops a persisted position whose section no longer matches the resource', () => {
|
||||
const layout = createEmptyResourceCanvasLayout('project-1', 'dependency');
|
||||
layout.positions = [
|
||||
@@ -555,6 +580,16 @@ describe('resource canvas layout model', () => {
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE + 1,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSafeProjectResourceCanvasCoordinate(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isSafeProjectResourceCanvasCoordinate(
|
||||
GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE - 1,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(deepPositions.map((position) => position.x)).toEqual([
|
||||
lastLegalColumn,
|
||||
lastLegalColumn,
|
||||
|
||||
@@ -43,18 +43,18 @@
|
||||
### 3.3 资源布局
|
||||
|
||||
- “按依赖”和“按类型”分别保存画布位置。
|
||||
- 当前只允许 dependency / type 自动布局与资源卡点击,不提供资源卡手动拖动入口。
|
||||
- dependency / type 自动布局与资源卡手动拖动并存;空项目分区展览仍只提供分区空态。
|
||||
- 切换布局模式后恢复该模式 sidecar 中已有坐标;历史手动坐标只读恢复,不删除、不重置、不迁移。
|
||||
- 新资源首次进入某个布局时才执行默认不重叠排版;已有坐标不得被自动排序覆盖。
|
||||
- 依赖布局使用资源生成/引用关系;类型布局按资源大类、子类型、尺寸规格排序。
|
||||
- 资源分区由资源投影固定,前端交互不能改变分类。
|
||||
- 资源卡手动拖动、拖动持久化、拖动性能与冲突后的重新拖动提示全部暂缓,不作为当前产品合同或验收条件。
|
||||
- 非空资源页支持资源卡手动拖动:超过移动阈值后乐观更新坐标与依赖线,结束时提交一次手动布局 CAS;拖动后的点击不触发详情。空画布拖拽仍只平移 viewport。
|
||||
|
||||
#### 3.3.1 资源管理三个串行阶段
|
||||
|
||||
1. 阶段一“资源卡本体化”:`dependency / type` 两种布局共用同一个资源卡组件和预览调度器。图片与安全 SVG 直接显示主体并保留透明棋盘底;图片卡按真实纵横比在统一边界内计算展示矩形,视频、音频、文档和项目版本继续使用稳定固定卡片。资源详情是叠加在原画板上的非模态独立卡片,以元数据、Rust 权威依赖关系和版本信息为主;打开详情不得卸载“新增资源 / 按依赖 / 按类型”、资源卡、依赖连线或重置 viewport、搜索和排序模式。
|
||||
2. 阶段二“分区独立缩放”(已完成)只适用于 `type` 模式,同时包含互不替代的分区可视高度和内容倍率。文档、项目版本、美术资源、音乐音效资源的标题栏分别保留高度减小 / 恢复 / 增大,并新增 `50%..200%` 内容缩放减小 / 百分比复位 / 增大;触摸板捏合、`Ctrl/Cmd + wheel` 与按钮使用同一倍率模型,普通双指滚动仍只滚动。高度与倍率都以 `projectId + type + category` 隔离并只保留在当前会话内存;倍率以分区 viewport 中的手势中心为锚点,逻辑 `x / y` 和媒体比例不变;非图片资源维持 `180×128`,美术图片按真实比例和统一边界计算本体矩形。最小高度固定容纳标题操作与至少一排卡片,最大高度以中央资源画布当前可用高度为上限。分区保持正常文档流,后续分区由外层资源画布滚动访问,单区内容在缩放后仍只在该区内部滚动。两类表现态都不修改 manifest、`game-creator-resource-layout.v1`、布局 CAS 或资源身份,不重建媒体控件、不抢走焦点,也不恢复卡片拖动。`dependency` 模式使用统一世界自由画板,不展示这些分区滚动与缩放操作。
|
||||
3. 阶段三“依赖布局聚类”(已完成):仅在 `dependency` 模式中,按固定资源分类对当前 Rust 只读图返回的同类型精确引用边和同类型聚合任务流做弱连通分组;任务流以流节点连接其成员,绝不展开成资源笛卡尔积,但只作为布局邻近提示,不绘制虚线、不进入画布关系说明。`dependencyDepth` 是唯一横向层级权威;每个相关簇先按“最小深度 -> 最小稳定资源 ID”排序,再在同层进行固定两轮由左至右、由右至左的中位数扫描,平局回退稳定资源 ID;同 SCC/环成员保持连续,环后资源继续按 Rust 深度推进。孤立资源是稳定单例组,紧凑排在相关簇之后,簇之间保持固定留白。跨分类精确引用不参与前端布局邻接,但会在统一世界 SVG 中显示并进入同源的无障碍关系说明;搜索只隐藏卡片及任一端点被隐藏的橙色精确引用边,不重算组或压缩坐标。历史 `manuallyPlaced=true` 坐标继续原样保留并参与自动坐标避让。聚类是可派生表现,不新增 manifest 字段、sidecar schema、SpacetimeDB 表或第二套依赖真相。
|
||||
2. 阶段二“分区独立缩放”是历史实现;2026-08-22 起仅在项目完全没有任何资源投影时保留“按类型式分区展览”作为空态。任一栏目出现资源后,`dependency / type` 两种模式均切换为栏目分页画布,默认停留在固定栏目顺序中的第一个非空栏目;大纲展示全部栏目,空栏目计数为 0 且可打开空画布,不再提供分区高度、分区滚动和分区倍率操作。
|
||||
3. 阶段三“依赖布局聚类”(已完成):仅在 `dependency` 模式中,按固定资源分类对当前 Rust 只读图返回的同类型精确引用边和同类型聚合任务流做弱连通分组;任务流以流节点连接其成员,绝不展开成资源笛卡尔积,但只作为布局邻近提示,不绘制虚线、不进入画布关系说明。`dependencyDepth` 是唯一横向层级权威;每个相关簇先按“最小深度 -> 最小稳定资源 ID”排序,再在同层进行固定两轮由左至右、由右至左的中位数扫描,平局回退稳定资源 ID;同 SCC/环成员保持连续,环后资源继续按 Rust 深度推进。孤立资源是稳定单例组,紧凑排在相关簇之后,簇之间保持固定留白。当前栏目内同类型精确引用参与布局并在该栏目 SVG 中显示;跨分类引用不参与布局,也不在分栏画布中强行连线。搜索只隐藏卡片及任一端点被隐藏的橙色精确引用边,不重算组或压缩坐标。历史 `manuallyPlaced=true` 坐标继续原样保留并参与自动坐标避让。聚类是可派生表现,不新增 manifest 字段、sidecar schema、SpacetimeDB 表或第二套依赖真相。
|
||||
|
||||
#### 3.3.2 本体化资源卡交互与性能合同
|
||||
|
||||
@@ -224,7 +224,7 @@ type ProjectWorkbenchViewState = {
|
||||
|
||||
### 5.2 资源画布布局(P1)
|
||||
|
||||
实现状态(2026-08-11):dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑与确定性依赖深度、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。依赖图加载完成前设布局初始化屏障,避免以临时 `dependencyDepth=0` 生成并持久化错误坐标。资源总览当前只允许自动布局与资源卡点击,资源卡手动拖动继续暂缓;素材创作无限画布独立支持 viewport 与图层变换,分区高度/倍率与 sidecar 完全分离。
|
||||
实现状态(2026-08-22):dependency / type 双模式通过项目内 CAS sidecar 独立持久化;dependency 模式由 Tauri Rust 只读构建关系拓扑与确定性依赖深度、前端 SVG 派生几何,图结构和线段均不写入布局 sidecar。依赖图加载完成前设布局初始化屏障,避免以临时 `dependencyDepth=0` 生成并持久化错误坐标。非空资源总览支持自动布局、资源卡点击与手动拖动;素材创作无限画布独立支持 viewport 与图层变换,分区高度/倍率与 sidecar 完全分离。
|
||||
|
||||
```ts
|
||||
type ProjectResourceCanvasLayout = {
|
||||
@@ -247,12 +247,12 @@ type ProjectResourceCanvasLayout = {
|
||||
|
||||
#### 5.2.1 字段语义
|
||||
|
||||
- `x / y` 是相对所属 `section` 内容原点的 CSS 像素坐标,落盘前四舍五入为非负整数;坐标不使用 viewport、页面或资源详情浮层坐标系。
|
||||
- `x / y` 是相对所属 `section` 内容原点的 CSS 像素坐标,落盘前四舍五入为整数;坐标不使用 viewport、页面或资源详情浮层坐标系。
|
||||
- `updatedAt` 是持久层生成的 Unix 毫秒时间戳,前端不得自行覆盖。
|
||||
- `revision` 从 `0` 开始;布局文件不存在时读取接口合成 `revision=0 / positions=[]`,首次成功写入返回 `revision=1`,后续每次成功 CAS 写入递增 `1`。JSON / Tauri / TypeScript 全链路合法范围固定为 `0..=9_007_199_254_740_991`(`Number.MAX_SAFE_INTEGER`),读取、返回或提交负数、小数、非有限值与超限整数都必须失败关闭。
|
||||
- 新资源第一次进入某个 mode 时由默认布局写入 `manuallyPlaced=false`。`manuallyPlaced=true` 仅用于兼容历史 sidecar 和保留底层合同;当前界面不会因指针操作新增该值。
|
||||
- 同一份布局中 `resourceId` 必须唯一。持久层允许暂时存在当前资源投影中没有的旧 ID,因为 Agent 文本成果等资源可能晚于 manifest 恢复;前端协调后必须在下一次成功写入中清除已确认失效的坐标。
|
||||
- 单份布局最多保存 `4096` 个位置,序列化文件不得超过 `2 MiB`;`resourceId` 最多 `512` 个 Unicode 字符,`x / y` 取值范围固定为 `0..=1_000_000`。
|
||||
- 单份布局最多保存 `4096` 个位置,序列化文件不得超过 `2 MiB`;`resourceId` 最多 `512` 个 Unicode 字符,`x / y` 取值范围固定为 `-1_000_000..=1_000_000`。
|
||||
|
||||
#### 5.2.2 本地存储与业务边界
|
||||
|
||||
@@ -305,9 +305,9 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
|
||||
#### 5.2.4 前端布局与协调合同
|
||||
|
||||
- 资源卡是可点击按钮,只负责选择资源并让中央主视窗进入当前唯一资源聚焦状态;不得绑定卡片级 `pointerdown / pointermove / pointerup / pointercancel` 拖动处理器。
|
||||
- 指针移动不得修改卡片 `x / y`、不得产生拖动预览、不得更新依赖线几何,也不得提交手动布局 CAS。卡片 title、cursor、`touch-action` 和 class 不得暗示可拖动。
|
||||
- dependency 默认布局按 Rust 返回的 `dependencyDepth` 形成横向层级;同层仅在所属固定分区内按精确引用、聚合 task-flow 的稳定邻接、连通簇和固定两轮中位数扫描确定纵向次序,随后寻找第一个不重叠位置。task-flow 只作为聚合超边参与分组和排序,不能生成资源两两边。dependency 自动布局使用专用 `48px` 列间走线区和 `40px` 行间走线区;相关簇以本簇最大层行数为高度,资源较少的层在该高度中居中,避免菱形 / 分叉关系一侧极短、另一侧过长。type 默认布局继续使用原有 `16px` 行列间距,并固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact`、`attachment`、`agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸、间距和 `0..=1_000_000` 坐标上限由前后端同名合同维护。超深依赖仍保留原始 `dependencyDepth` 业务真相,但显示坐标在上限列确定性饱和并纵向避让;自动布局在 IPC 前必须保证全部 `x / y` 合法,不能向 Tauri 永久重放必然失败的坐标。
|
||||
- 资源卡容器负责拖动,内部“打开详情”和媒体控制仍可点击。Pointer 距离小于 `5px` 时保持点击语义;超过阈值后按当前 viewport scale 换算世界坐标,乐观更新卡片 `x / y`、`is-dragging` class 与依赖线拖动预览。拖动可越过所属 section 原点,坐标允许落在 `-1_000_000..=1_000_000`。
|
||||
- 指针释放且拖动已改变时提交一次 `manuallyPlaced=true` 的布局 CAS;pointer cancel 恢复起始坐标且不提交。拖动后的释放点击被抑制,不打开资源详情。
|
||||
- dependency 默认布局按 Rust 返回的 `dependencyDepth` 形成横向层级;同层仅在所属固定分区内按精确引用、聚合 task-flow 的稳定邻接、连通簇和固定两轮中位数扫描确定纵向次序,随后寻找第一个不重叠位置。task-flow 只作为聚合超边参与分组和排序,不能生成资源两两边。dependency 自动布局使用专用 `48px` 列间走线区和 `40px` 行间走线区;相关簇以本簇最大层行数为高度,资源较少的层在该高度中居中,避免菱形 / 分叉关系一侧极短、另一侧过长。type 默认布局继续使用原有 `16px` 行列间距,并固定按“资源子类型 -> 媒体类型 -> 名称 -> 资源 ID”稳定排序。布局模型的 `subtype` 必填:manifest 资产使用 `asset.kind`,任务产物、导入附件与 Agent 文本成果分别使用稳定的 `task-artifact`、`attachment`、`agent-result`,不得以缺失值或显示文案兜底;资源协调签名必须包含 subtype。卡片尺寸、间距和 `-1_000_000..=1_000_000` 坐标范围由前后端同名合同维护。超深依赖仍保留原始 `dependencyDepth` 业务真相,但显示坐标在上限列确定性饱和并纵向避让;自动布局在 IPC 前必须保证全部 `x / y` 合法,不能向 Tauri 永久重放必然失败的坐标。
|
||||
- type 模式资源集合变化时保留全部仍存在的坐标,只为新 ID 计算默认位置,并删除已确认失效的旧 ID。dependency 模式只永久保留 `manuallyPlaced=true` 的历史坐标;`manuallyPlaced=false` 属于可派生自动位置,在 Rust 关系图首次就绪、`dependencyDepth` 或资源拓扑身份签名(精确引用端点和聚合 task-flow 成员)变化后按最终拓扑确定性重算。签名以稳定资源 ID 的规范端点 / 成员序列生成固定大小摘要,不使用显示名称或浏览器测量值;自动重算不得移动手动坐标,协调结果与持久布局逐项一致时不得产生 CAS 写入。
|
||||
- 搜索或筛选只隐藏卡片,不删除、压缩或重排其坐标;清空搜索后恢复原位置。
|
||||
- 窗口尺寸变化只改变可视范围和分区滚动边界,不回写或裁切持久坐标。当前客户端继续以 `1280×800` 横屏合同验收。
|
||||
@@ -317,10 +317,10 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
- 自动协调 CAS 冲突时直接载入返回的最新布局;仍需协调时可以基于权威 revision 最多追加 `2` 次重试,持续跨窗口写入时不得无限自旋。当前提示只说明“布局已在其他窗口更新”,不得要求用户重新拖动。
|
||||
- 缺少 Tauri bridge 的浏览器开发态可以保留当前会话内布局用于界面测试,但不得宣称已经持久保存。
|
||||
|
||||
#### 5.2.5 暂缓的手动拖动合同
|
||||
#### 5.2.5 手动拖动合同
|
||||
|
||||
- Pointer Events 二维拖动、移动阈值、拖动态 class、乐观手动坐标与结束时单次 CAS 提交暂缓。
|
||||
- 手动意图折叠、冲突后丢弃旧手动坐标、重新拖动提示和 4096 卡片拖动帧预算暂缓,不进入当前验收。
|
||||
- Pointer Events 使用 `5px` 移动阈值,拖动期间指针捕获到卡片,画布普通滚轮仍切栏目,`Ctrl/Cmd + wheel` 仍以真实画布指针位置缩放。
|
||||
- 拖动提交沿用现有单写者 FIFO 与 CAS 冲突重试;冲突提示沿用“布局已在其他窗口更新”,用户可重新拖动。历史 `manuallyPlaced=true` 坐标仍优先保留。
|
||||
- 已有 Hook 手动意图、命令式 SVG preview、sidecar 字段和 Rust CAS 基础设施可以保留为未接线技术资产;当前资源卡不得调用这些入口。
|
||||
|
||||
#### 5.2.6 资源依赖关系图层
|
||||
@@ -328,8 +328,8 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
- 阶段五实现状态(2026-08-03):dependency 自动排列同时消费任务 DAG 与精确资源引用。Rust 把可信 producer 的任务深度作为资源深度下限,再对 `asset-reference` 图做迭代式 SCC 压缩与确定性层级传播;被引用资源位于引用资源之前,同一引用环共享稳定深度,环后资源继续递增,没有引用关系的资源保持默认不重叠位置。布局深度通过独立 `dependencyDepths` 返回,不能把 producer assignment 冒充全部资源的布局结果。
|
||||
- `producerMappingTruncated=true` 只关闭依赖有界 Agent DB 审计的 `producerAssignments`、`taskFlows` 和 `cyclicTaskIds`。Rust 返回的 `dependencyDepths` 仍是 manifest / 精确引用 read model 的权威结果,前端必须过滤未知资源、负数、非整数和非安全整数后继续消费;不得因 producer 截断清空全部深度,也不得在前端重算替代深度。`referenceEdges`、connection index 中的 reference 关系、`cyclicResourceIds` 和 unresolved reference 继续保持可信。
|
||||
- 图层只在 dependency 模式挂载;type 模式不得渲染 SVG、连线或 marker。切换 mode、切换项目或卸载工作台时必须销毁旧图层,并清理尺寸观察和窗口事件监听。
|
||||
- 输入固定为当前资源投影的全部卡片身份 / 统一世界坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、世界 viewport 变换与原生 SVG path / marker。dependency 内容平面持有一个覆盖统一世界的 SVG,SVG 与卡片直接消费同一世界坐标和 viewport transform;端点主路径不得通过 `getBoundingClientRect` 拼接屏幕坐标。SVG 位于资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击。
|
||||
- `asset-reference` 表示精确资源引用;当前可见端点之间的同分类与跨分类引用都进入统一世界 SVG 和无障碍关系说明,使用明亮橙色实线与实心箭头。只有同分类引用参与自动布局邻接,跨分类引用不形成布局簇或边界偏置。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
|
||||
- 输入固定为当前栏目资源投影的卡片身份 / 栏目内坐标与 Tauri Rust 返回的 `ProjectResourceGraph` 只读 DTO;Rust 负责资源过滤、去重、迭代式环检测、SCC 压缩后的确定性依赖深度、任务流聚合和一跳连接索引,前端只负责 DTO 防御归一化、当前栏目 viewport 变换与原生 SVG path / marker。dependency 当前栏目持有一个覆盖该栏目内容的 SVG,SVG 与卡片直接消费同一坐标和 viewport transform;端点主路径不得通过 `getBoundingClientRect` 拼接屏幕坐标。SVG 位于资源卡底层并设置 `pointer-events: none`,不得引入 D3、React Flow 等图表库,也不得阻断卡片点击。
|
||||
- `asset-reference` 表示精确资源引用;当前可见且两端均属于当前栏目的引用进入该栏目 SVG 和无障碍关系说明,使用明亮橙色实线与实心箭头。只有同分类引用参与自动布局邻接,跨分类引用不形成布局簇、边界偏置或分栏连线。`GameCreationAppAssetManifestEntry.source.referenceResourceIds` 中的外部资源 ID 必须先唯一匹配另一项资产的 `source.resourceId`,再映射为当前资源卡 ID;缺失、重复或已删除的目标均不得渲染幽灵连线。
|
||||
- `task-flow` 表示同一资源类型内的任务产物流转,但资源画布不再绘制灰色虚线或任务流 marker,也不把它加入 `aria-describedby` 关系说明。它只按 `sourceTaskId -> targetTaskId + section` 作为聚合超边参与 dependency 连通簇和中位数排序,禁止对上下游资源生成笛卡尔积邻接;详情仍可展示 Rust read model 已证明的任务信息。
|
||||
- 画布资产 producer 只能来自 `agent.runtime.canvas.asset_generate` 的 `assetId -> agentId` 审计且 `agentId` 必须存在于当前 manifest;External Editor `source.taskId` 属于平台生成任务命名空间,禁止当作 manifest task ID。证据缺失、冲突或有界审计读取未覆盖时不生成对应 task flow,不猜测归属。
|
||||
- 图模型必须对资源引用图和完整任务依赖图做迭代式环检测,不得用无界递归遍历;参与环的可见边保留渲染并标记 cyclic,环本身不能造成重复生成或死循环。
|
||||
@@ -337,13 +337,13 @@ type UpdateProjectResourceCanvasLayoutResult =
|
||||
- 搜索只允许为当前可见端点生成几何;任一精确引用端点隐藏时该橙线隐藏。task-flow 不产生显示几何,搜索也不因此重排布局。
|
||||
- 同类型精确引用按稳定边 ID 和对端次序为同一卡片同侧的多条边分配独立端口;横向层级可用时优先左右连接,同列或横向间隙不足时才上下连接。同轴端点直接用直线,需转向时使用正交线段与最大 `10px` 的小圆角,不使用大范围贝塞尔控制柄。端口顺序不使用显示名称、随机数或浏览器枚举顺序,相同输入必须产生相同路径。资源点击只进入中央聚焦并保留当前选中卡片,不改变依赖卡片或连线的颜色、线宽与透明度;关系线始终直接展示,不提供点击后的上下游高亮或无关线弱化。
|
||||
- 资源卡 Pointer Move 不改变基础 positions 或 SVG 几何。连线只随布局读取、资源自动协调、搜索、项目切换或 section origin 变化而更新。
|
||||
- 统一世界 dependency 平面最多构造一个 `ResizeObserver`;observer 只维护世界 viewport,不测量或重建卡片屏幕端点。世界 extent 必须为最右侧自环和箭头保留视觉 gutter,但不得修改卡片坐标或布局 sidecar。
|
||||
- 拖拽、滚轮平移、Ctrl/Meta + 滚轮缩放和窗口 resize 共用单一世界 viewport 变换;原生 `{ passive: false }` wheel 监听在 dependency 模式阻止 WebView 默认滚动或缩放,type 模式普通滚轮继续交给原生分区滚动。项目 / mode 切换或卸载时必须清理 observer、wheel 与 window resize 监听。
|
||||
- 当前 dependency 栏目平面最多构造一个 `ResizeObserver`;observer 只维护当前栏目 viewport,不测量或重建卡片屏幕端点。栏目 extent 必须为最右侧自环和箭头保留视觉 gutter,但不得修改卡片坐标或布局 sidecar。
|
||||
- 非空资源页的普通滚轮固定切换栏目;拖拽平移、Ctrl/Meta + 滚轮缩放和窗口 resize 共用当前栏目 viewport 变换。原生 `{ passive: false }` wheel 监听必须阻止 WebView 默认滚动或缩放,并对连续滚轮事件做节流。完全空项目的分区展览保留原生分区滚动。项目 / mode 切换或卸载时必须清理 observer、wheel 与 window resize 监听。
|
||||
- 阶段五不改变手动位置边界:已有 `manuallyPlaced=true` 坐标原样保留,资源引用新增或变化只允许重新派生 `manuallyPlaced=false` 的自动坐标;任务流继续按任务对与资源分区聚合,禁止为了布局分组生成资源笛卡尔积,且不进入 SVG。
|
||||
|
||||
### 5.3 资源类型与替换兼容性(P1)
|
||||
|
||||
实现状态(2026-08-10):当前资源投影已收口到固定的“文档 -> 项目版本 -> 美术资源 -> 音乐音效资源”四区。文档接收受支持的 UTF-8 文档/代码和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术接收图片、SVG、动画和视频类产物;音频接收 manifest 资产、上传登记资产和已完成任务 `artifacts` 明确声明的音频产物。无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。
|
||||
实现状态(2026-08-22):当前资源投影与栏目页顺序收口为“文档 -> 美术资源 -> 音乐音效资源 -> 游戏代码 -> 项目版本”。文档接收受支持的 UTF-8 文档、代码资产中的文档类型和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术接收图片、SVG、动画和视频类产物;音频接收 manifest 资产、上传登记资产和已完成任务 `artifacts` 明确声明的音频产物;游戏代码接收 Direct Codex / 任务产物登记的 HTML、CSS 和 JavaScript。无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录。
|
||||
|
||||
资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
|
||||
|
||||
@@ -456,7 +456,7 @@ type ProjectAgentMudPointAttribution = {
|
||||
|
||||
### P1
|
||||
|
||||
- 已实施依赖/类型两套坐标持久化、首次默认不重叠布局、历史坐标跨重启恢复与自动协调 CAS 冲突处理;资源卡手动拖动暂缓。
|
||||
- 已实施依赖/类型两套坐标持久化、首次默认不重叠布局、历史坐标跨重启恢复、自动协调 CAS 冲突处理与资源卡手动拖动。
|
||||
- 资源关系线在布局持久化验收通过后单独实施,不与本切片捆绑伪造完成。
|
||||
- 已实施正式版本不可变模型、版本卡、父子关系与引用资源高亮;“编辑资源”可追加继承源绑定并记录提示词的子版本,资源直接替换、运行版本切换和兼容性迁移仍待后续切片。
|
||||
- 素材创作无限画布阶段一按权威专题一次交付图片导入、编辑、生成、导出、草稿恢复、正式本地回写、即时投影和焦点竞态闭环。
|
||||
@@ -490,25 +490,25 @@ type ProjectAgentMudPointAttribution = {
|
||||
|
||||
1. 同一项目在 dependency 与 type mode 分别读取并协调布局,关闭并重启客户端后恢复各自 sidecar 坐标;历史 `manuallyPlaced=true` 坐标保持不变。
|
||||
2. 新资源进入任一 mode 时获得不重叠默认位置,现存资源坐标保持逐项不变;删除资源后,下一次成功写入不再包含已确认失效的 ID。
|
||||
3. 资源卡 title、cursor、`touch-action` 与 class 只表达可点击;Pointer Down / Move / Up / Cancel 不改变坐标、不产生拖动预览、不提交手动布局 CAS,点击仍打开当前资源详情。
|
||||
3. 资源卡 Pointer Down / Move / Up / Cancel 支持移动阈值、拖动预览、依赖线同步与结束时一次手动布局 CAS;未超过阈值或点击媒体控制时保留原点击语义。
|
||||
4. 两个窗口基于同一 revision 执行资源自动协调写入时最多一个成功;失败方收到 `conflict` 与最新完整布局,界面不静默覆盖成功方结果。
|
||||
5. 布局文件缺失的旧项目可以无迁移打开;损坏、未知 schema、身份冲突、超限和链接文件失败关闭,且原文件不被空布局覆盖。
|
||||
6. 布局读写不改变 manifest、游戏项目 mutation revision、Runtime verification、Agent 权限与预览状态。
|
||||
7. `1280×720` 最小横屏下全部资源可通过分区滚动访问,不出现页面级横向或纵向溢出,右侧对话和底部 Agent 状态栏保持可见;`1280×800` 继续作为默认窗口基线。
|
||||
7. `1280×720` 最小横屏下完全空项目可通过分区展览查看全部栏目,非空项目可通过栏目大纲和滚轮访问全部非空栏目,不出现页面级横向或纵向溢出,右侧对话和底部 Agent 状态栏保持可见;`1280×800` 继续作为默认窗口基线。
|
||||
|
||||
### 7.3 P1 资源依赖关系图验收
|
||||
|
||||
1. dependency 模式在统一世界中显示当前可见端点之间的全部合法精确引用,包括跨分类引用,并使用对画布背景至少 `3:1` 对比度的橙色实线箭头;聚合任务流只参与同类型布局,不绘制灰色虚线或 marker。type 模式没有图层或连线。
|
||||
1. dependency 模式在当前栏目中显示两端均属于该栏目且当前可见的合法精确引用,并使用对画布背景至少 `3:1` 对比度的橙色实线箭头;聚合任务流只参与同类型布局,不绘制灰色虚线或 marker。type 模式没有图层或连线。
|
||||
2. 精确引用只接受唯一有效的外部资源 ID 映射,删除或不存在的资源不产生幽灵连线。
|
||||
3. 多资源任务依赖按资源类型拆分后只形成布局超边,以 `O(S+T)` 成员关系参与聚类,不产生 `S×T` 邻接、SVG 主线或端点分支;跨分类精确引用只显示,不参与布局聚类。
|
||||
3. 多资源任务依赖按资源类型拆分后只形成布局超边,以 `O(S+T)` 成员关系参与聚类,不产生 `S×T` 邻接、SVG 主线或端点分支;跨分类精确引用不参与布局聚类,也不在分栏画布中显示。
|
||||
4. 资源引用环和无资源产物参与的任务环都可被有限遍历识别,界面不死循环。
|
||||
5. 搜索触发端点过滤;资源点击不改变上下游卡片或任何连线的视觉状态,资源卡指针移动不更新线段,点击与中央聚焦行为不回归。
|
||||
6. 切换布局模式或项目后旧 SVG、ResizeObserver 与窗口监听全部清理;图层从不写入 layout sidecar、manifest 或其它持久化。
|
||||
7. 4096 资源链式 fixture 继续验证拓扑、聚合复杂度和自动布局性能;拖动局部更新与真实 Chromium 拖动帧预算暂缓,不作为当前验收条件。最右侧自环与箭头仍需完整显示。
|
||||
7. 4096 资源链式 fixture 继续验证拓扑、聚合复杂度和自动布局性能;拖动局部更新只影响当前卡片和依赖线,不重算全图布局。最右侧自环与箭头仍需完整显示。
|
||||
8. Rust 图读取延迟时,dependency sidecar 在图进入 `ready / failed` 前没有读取或写入;首次布局直接使用 Rust 返回的最终 producer 与 dependency depth。Agent DB 有界读取截断时 producer、task flow 与 `cyclicTaskIds` 失败关闭,精确 manifest 引用及 Rust 返回的合法 `dependencyDepths` 继续到达布局层。重新打开包含深度 `0 / 1 / 2` 自动坐标的旧布局时不得降成全 `0` 或持久化扁平布局;手动位置逐项不变,自动位置按最终拓扑协调且相同结果不增加 revision。
|
||||
9. 依赖 SVG 作为装饰层不可聚焦并对辅助技术隐藏;画布通过关联的视觉隐藏文本逐条说明当前可见的橙色精确引用,搜索过滤或模式切换后文本与可见关系同步变化。task-flow 不进入该关系说明。
|
||||
10. 精确引用使用橙色实线和可见箭头;dependency 自动卡片之间保留横向 `48px`、纵向 `40px` 走线区,相关簇的窄层按本簇最大层高度居中。不同横向层级且卡片间有空间时使用左右端口优先的横线,同列、同深度或横向空间不足时才使用上下端口纵向降级。同行 / 同列直接连线,需要转向时只使用正交线段与最大 `10px` 小圆角。同一卡片同侧的多条精确引用按稳定边 ID 与对端位置分配不同端口;自引用继续在卡片右侧外绕,箭头端点与卡片保留固定显示间隙。
|
||||
11. 统一世界 SVG 必须位于拥有全部 dependency 资源卡的内容平面内,以同一世界坐标随 viewport 平移和缩放同步移动;滚动 / 缩放不得通过异步 DOM 屏幕测量重新绑定端点。世界 viewport 裁剪 SVG 与卡片,搜索隐藏精确引用任一端点时整边及对应无障碍说明同时隐藏。
|
||||
11. 当前栏目 SVG 必须位于拥有该栏目 dependency 资源卡的内容平面内,以同一栏目坐标随 viewport 平移和缩放同步移动;滚动 / 缩放不得通过异步 DOM 屏幕测量重新绑定端点。栏目 viewport 裁剪 SVG 与卡片,搜索隐藏精确引用任一端点时整边及对应无障碍说明同时隐藏。
|
||||
|
||||
### 7.4 P1 正式项目版本阶段六验收
|
||||
|
||||
@@ -570,7 +570,7 @@ type ProjectAgentMudPointAttribution = {
|
||||
## 8. 非目标
|
||||
|
||||
- 当前不开放“新增资源”产品入口。图片精修候选保持私有且不污染资源总览;“设为最终图”只允许保持原 asset ID、以新正式文件和事务方式切换 manifest 指针,不原地覆写旧文件。其他资源编辑继续追加派生 asset 或子版本。
|
||||
- 资源总览不实现资源卡手动拖动、通用聚焦工具栏/侧边栏、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因;资源详情仅为不影响背景画板的非模态卡片。
|
||||
- 资源总览不实现通用聚焦工具栏/侧边栏、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因;资源详情仅为不影响背景画板的非模态卡片。
|
||||
- 素材创作不实现高级蒙版/毛发级抠图、图集、角色动画、视频时间线或音频波形级编辑;图片画布提供现役编辑闭环,其他类型使用统一非破坏性派生面板。
|
||||
- 资源聚焦、搜索、筛选、dependency/type mode 与分区滚动/倍率均为当前会话态;打开详情不得改变这些状态。素材创作 viewport、候选图层、generation 占位和最后最终图提交投影使用独立草稿 schema,不能混用资源总览 sidecar。
|
||||
- 不修改 SpacetimeDB schema。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 目标
|
||||
|
||||
资源管理的“按依赖”视图使用统一世界坐标的自由画板,支持拖拽平移、滚轮平移以及按住 Ctrl/Meta 的锚点缩放。资源详情是叠加在资源画板之上的非模态卡片;打开、切换和关闭详情都不得卸载背景工具栏、资源卡、依赖连线或重置 viewport、搜索和排序模式。
|
||||
资源管理在空项目中展示现有按类型式分区展览。任一栏目出现资源后,“按依赖”和“按类型”共用同一套栏目分页画布:每个非空栏目拥有一个铺满资源管理区域的独立画布,左侧显示飞书文档式栏目大纲,顶部显示当前栏目标题,底部显示下一页标题;普通滚轮切换栏目,按住 Ctrl/Meta 的滚轮以指针为锚点缩放当前画布。两种模式唯一差异是“按依赖”在当前栏目内绘制引导线。资源详情是叠加在资源画板之上的非模态卡片;打开、切换和关闭详情都不得卸载背景工具栏、资源卡、依赖连线或重置 viewport、搜索和排序模式。
|
||||
|
||||
图片资源从“编辑资源”进入持续精修草稿后,点击任意图片直接在图片下方显示快速编辑卡。每次快速编辑创建新的候选图,不覆盖来源图层;生成任务立即在画布中创建占位并进入任务侧栏。用户显式选择候选图“设为最终图”后,保持原资产 ID 不变,以事务方式切换 manifest 指向的正式 PNG;精修草稿和其它候选图继续保留。
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
|
||||
## 权威数据与持久化
|
||||
|
||||
### 栏目分页投影与文档缺失边界
|
||||
|
||||
- 非空栏目页只由当前权威资源投影构造:manifest assets、已完成任务的 artifacts、用户已导入附件和持久化 Agent 文本回执。前端不得扫描项目目录自行发明资源。
|
||||
- 文档栏因此只有在上述来源存在 Markdown / TXT / JSON / YAML 等文档类条目,或存在 Agent 文本回执时才出现。若 manifest 没有文档资产、任务 artifacts 未完成且没有附件 / 回执,资源页不显示文档页;这是生成或登记缺失,不由 UI 补假数据。
|
||||
- Direct Codex 的游戏生成链路以 `game/index.html`、`game/style.css`、`game/game.js` 作为代码资产登记;是否额外产出设计文档由当前任务和 Agent 决定,资源页只消费已登记结果。
|
||||
|
||||
### 持续精修草稿
|
||||
|
||||
- 一个图片 `sourceAssetId` 唯一发现一个活动精修 `draftId`;重新进入时恢复原草稿,不依赖进程内状态或创建平行画布。
|
||||
@@ -34,6 +40,31 @@
|
||||
- canonical 来源缓存属于本地私有恢复数据,不写入 manifest。缓存身份至少绑定平台服务身份、平台 owner、Editor Project ID、源图片 SHA256 与 canonical kind;任一身份或图片内容变化都必须重新登记。
|
||||
- 缓存命中时复用已登记资源,避免每次快速编辑重复上传。平台明确返回来源不存在或类型不支持且请求尚未入队时,可以失效缓存并重建一次;不确定受理、已受理和对账状态不得换幂等键重提。
|
||||
|
||||
### 最终图事务恢复对账问题与修复
|
||||
|
||||
现象:用户连续两次“设为最终图”后重新打开画布,恢复阶段返回 `reconciliation-required`,前端提示“安全恢复原任务需要对账 / 素材画布存在无法自动证明的未完成事务”。
|
||||
|
||||
根因:
|
||||
|
||||
- 提交成功后 transaction 目录不会归档,`recover_asset_canvas_transactions_at` 会重放全部历史事务。
|
||||
- `recover_asset_canvas_transaction_locked` 的 `ledger=Committed` 分支要求当前 manifest 中该 asset 仍指向本事务的 `finalImageRelativePath`;后续提交已把同一 asset 指向新的 `assets/canvas/<name>--<commitId>.png`,`asset_present=false` 后直接 `mark_asset_canvas_reconciliation_locked`,没有先执行 `find_superseding_asset_canvas_commit`(该检查目前只在 unresolved 分支生效)。
|
||||
- 前端 `recover()` 只要存在任一 `reconciliation-required` outcome 就把整次恢复判失败,因此一个已被后续提交取代的旧事务会阻塞整个画布打开。
|
||||
- 现有测试此前只覆盖“第一个事务被强制标记 unresolved 后由后续已提交事务取代”,缺少“两个事务都成功提交后恢复”的用例,因此未暴露。
|
||||
|
||||
修复合同:
|
||||
|
||||
1. `ledger=Committed` 分支在进入 reconciliation 前先执行同一套 supersede 证明;命中则把旧 journal/ledger 标记为 `Superseded`,返回 `Superseded`,不得阻塞画布打开。
|
||||
2. supersede 证明继续要求后续事务已完整提交、after manifest/revision 与当前状态一致、正式文件哈希匹配,且后续事务的 before 快照精确衔接旧事务的 after 快照;不得仅凭时间戳或路径变化猜测。
|
||||
3. 重复恢复幂等:已 `Superseded` 的事务直接返回既有状态,不再改成对账。
|
||||
4. 回归测试覆盖“两个连续成功提交后恢复”:旧事务必须收敛为 `Superseded`,新事务为 `AlreadyCommitted`,整轮恢复不得出现 `ReconciliationRequired`。
|
||||
5. 恢复失败可观测性仍保留后续增强方向:失败结果应携带 outcomes 明细(commitId、status、stage、证据摘要),前端展示具体事务并支持重试。
|
||||
|
||||
验收:
|
||||
|
||||
- 连续两次“设为最终图”后关闭再打开画布不再出现对账提示,且最终 manifest 指向最新提交。
|
||||
- 现有 fault-injected 恢复用例(`RevisionInstalled`、`LedgerCommitted` 等)全部保持通过,未出现新的 `ReconciliationRequired` 回归。
|
||||
- 重复恢复幂等,已 `Superseded` 事务不再重放为对账。
|
||||
|
||||
### 本地图片批量导入
|
||||
|
||||
- Tauri 正式链路使用原生多选文件对话框,不以隐藏 DOM `input[type=file]` 作为唯一入口。取消选择返回受控 `cancelled`,不修改草稿。
|
||||
@@ -50,11 +81,15 @@
|
||||
|
||||
## 操作边界
|
||||
|
||||
- 栏目顺序固定为 `文档 -> 美术资源 -> 音乐音效资源 -> 游戏代码 -> 项目版本`。完全空项目显示全部栏目的分区展览;任一栏目出现资源后,分页大纲展示全部栏目,默认停留在该顺序中的第一个非空栏目,空栏目计数为 0 且可打开空画布。
|
||||
- 普通滚轮向下切到下一栏目、向上切到上一栏目并循环;短时间连续滚轮事件需要节流,避免触控板一次惯性滚动跨多页。点击大纲或底部“下一页”标题是显式切页入口。
|
||||
- 每个栏目画布保留独立 viewport;切换栏目后先按该栏目内容适配视口。空白处拖拽平移画布,资源卡拖拽移动卡片并更新依赖线,Ctrl/Meta 缩放只作用于当前栏目,不能牵动其它栏目。
|
||||
- 依赖画布复用 `@genarrative/image-canvas-core` 的 viewport 计算,并复用现有资源卡片、布局和依赖连线模型。
|
||||
- 依赖模式不使用资源分区滚动条、分区缩放或分区高度操作作为主要导航;分区仍是画布世界中的视觉分组。
|
||||
- 依赖画布存在不可见导航边界:以当前项目全部资源的权威布局矩形、真实卡片尺寸、分区标题和世界 extent 的并集为内容范围,再在屏幕坐标四周外扩 `96px` 安全留白。搜索、详情卡和临时隐藏不得缩小边界,只有项目资源或权威布局真正变化时才重算。
|
||||
- 滚轮平移、指针拖动画布、Ctrl/Meta 缩放、复位以及容器 resize 后都必须经过同一个 viewport 约束。缩小下限是“全部内容加安全留白刚好可见”的 fit scale;内容小于视口时居中,内容大于视口时四条边最多移动到安全留白位置,禁止把资源整体拖出屏幕。边界只约束会话 viewport,不写入 manifest 或资源布局 sidecar,也不显示可见边框。
|
||||
- 统一世界画板显示当前可见资源之间的全部合法精确引用,包括跨视觉分组引用;装饰 SVG 与视觉隐藏的关系说明消费同一组可见边,搜索隐藏任一端点时两者同步移除。任务流仍只参与同类型布局聚类,不绘线也不进入关系说明。
|
||||
- 非空状态不使用资源分区滚动条、分区缩放或分区高度操作作为主要导航;栏目通过大纲、底部下一页标题和滚轮切换。
|
||||
- 当前栏目画布背景是无限的:用户可以将 viewport 沿 x/y 任意方向平移,画布不以资源 extent 作为导航边界,也不显示可见画布边缘。资源卡片的持久化布局坐标允许落在 `-1_000_000..=1_000_000`,用于支撑元素位于世界原点左上方;超出该范围仍拒绝写入,避免持久化非法布局。这与 viewport 能否继续平移是两层独立语义。搜索、详情卡和临时隐藏不得改变 viewport。
|
||||
- 普通滚轮切换栏目,指针拖动空白平移画布,Ctrl/Meta 缩放、复位以及容器 resize 后都必须保持同一套 viewport 数据流。切换栏目、切换排序模式或显式复位时才重新适配内容;图片尺寸测量、布局拖动或资源 extent 变化只归一化当前 viewport,不得意外重置用户已经完成的平移和缩放。普通平移不夹取 x/y;缩放仍受共享画布的最小/最大比例限制,初次 fit 只使用 `96px` 内缩作为视觉留白。
|
||||
- 资源卡拖动使用 `5px` 阈值区分点击与移动;移动期间按当前 scale 乐观换算世界坐标、显示拖动态并同步依赖线,释放时提交一次 `manuallyPlaced=true` 布局 CAS,取消则回滚预览且不提交。拖动后的释放点击不打开详情。
|
||||
- 依赖模式只在当前栏目画布内显示两端都属于该栏目的合法精确引用;装饰 SVG 与视觉隐藏的关系说明消费同一组可见边,搜索隐藏任一端点时两者同步移除。任务流仍只参与同类型布局聚类,不绘线也不进入关系说明。
|
||||
- 资源详情卡包含元数据、媒体预览和“编辑资源”操作,但不使用全屏 backdrop、不声明 `aria-modal=true`、不把 `focusedResource` 作为背景工具栏渲染条件。桌面端允许继续操作背景画板;窄屏可以使用有边界的贴边卡,但背景组件必须保持挂载。
|
||||
- 资源详情卡定位相对 `.game-workbench-stage`,桌面端在中间主视窗居中并受主视窗宽高边界约束;禁止使用相对整个窗口的右上角 `position: fixed` 定位。
|
||||
- 图片选中后的浮动工具栏整体退役。普通点击图片直接打开唯一的图片下方快速编辑卡;拖动不误触发,点击另一张图片切换卡片,点击画布空白关闭卡片。
|
||||
@@ -73,20 +108,22 @@
|
||||
|
||||
## 验收
|
||||
|
||||
1. 在依赖视图拖拽画布可以平移,滚轮可以平移,Ctrl/Meta + 滚轮以指针位置为锚点缩放,复位按钮可以适配全部依赖内容;极端拖拽和连续缩小均停在全部资源加 `96px` 安全留白形成的隐藏边界,搜索过滤不会改变该边界。
|
||||
2. 同类型和跨视觉分组的合法精确引用均在统一世界中连线,并具有与当前可见连线一致的无障碍关系说明;搜索隐藏任一端点后连线和说明同时消失。
|
||||
3. 点击任意资源后“新增资源 / 按依赖 / 按类型 / 复位”仍保持挂载和原状态;详情为非模态独立卡片,背景画板 viewport、搜索、排序、卡片和连线不卸载、不重置。
|
||||
4. 图片资源点击“编辑资源”后恢复唯一活动 refine 草稿,入口图进入默认参考;点击任意图片直接在其下方显示快速编辑卡,图片上方不再出现浮动工具栏。
|
||||
5. 快速编辑卡底部显示适用的删除、设为最终图和修改动作。拖动图片不误触发卡片;删除只影响草稿图层;候选设为最终图后保持原 asset ID、更新正式 PNG,草稿及其它候选仍可继续编辑。
|
||||
6. 点击修改后立即创建避让现有内容的生成占位和任务侧栏记录;成功图在同一落点出现,失败只影响该占位和任务,重启后恢复原 generation/operation,不重新扣费提交。
|
||||
7. refine 请求通过严格 DTO 合同且不携带非法 `assetKind`;参数、来源类型、余额、鉴权和对账错误显示不同的安全错误码。
|
||||
8. 1:1、16:9、9:16、2:3、3:2 和极端横竖图片按真实比例显示并受边界限制;卡片不重叠,世界 extent、复位视口和依赖连线端点正确。非图片资源尺寸不变。
|
||||
9. GIF、AVIF、BMP、SVG 等不满足栅格快速编辑能力的资源继续使用能力模型中的降级编辑路径,不伪装成可提交 PNG 候选。
|
||||
10. 历史 `game-background` 等私有类型来源不得直接作为 refine 主来源;首次快速编辑完成 canonical 登记,后续同源请求复用缓存,并实际到达 `candidate-ready`。
|
||||
11. 点击导入可以原生多选图片;成功批次只推进一次草稿 revision、立即出现在当前视口并被选中,取消和失败不改变草稿且不遗留部分导入。
|
||||
12. 明确失败任务可从占位或任务侧栏归档,重启后不恢复;`reconciliation-required` 不显示删除能力。任务列表具有与现役美术画布一致的独立按钮、双 Tab、状态图标与时间/进度信息;折叠后只保留按钮且不丢任务数据,新建任务自动展开。
|
||||
13. 画布带运行中 generation 重新进入时,在远端恢复完成前已经可选图、平移和继续编辑;后台恢复继续推进原 operation,不重复 POST、不重新扣费。阻断性提交/恢复失败的遮罩覆盖整个 Tauri 窗口。
|
||||
14. 已登记 refine 资产的候选设为最终图后保持原 `assetId`、原 `source.resourceId` 与新正式 PNG 路径;`revision-installed` 中断后重新加载可前向收敛为 committed。
|
||||
1. 完全空项目在按依赖与按类型下都显示相同的分区展览;任一栏目出现资源后,两种模式都切换为栏目分页画布,大纲包含全部栏目,空栏目计数为 0 且可打开空画布。
|
||||
2. 栏目顺序为 `文档 -> 美术资源 -> 音乐音效资源 -> 游戏代码 -> 项目版本`,游戏代码不再固定在首位;普通滚轮可循环切换栏目,点击大纲和底部下一页标题也可切页。
|
||||
3. 在当前栏目内拖拽空白可以无限平移画布,拖拽资源卡可以移动卡片并同步依赖线,Ctrl/Meta + 滚轮以指针位置为锚点缩放,复位按钮可以适配当前栏目内容;普通平移不会因资源 extent、图片测量或窗口 resize 被拉回,连续缩放仍停在共享画布的最小/最大比例范围内。
|
||||
4. 按依赖模式显示当前栏目内同类型两端资源的合法精确引用,并具有与当前可见连线一致的无障碍关系说明;按类型模式不显示引导线;搜索隐藏任一端点后连线和说明同时消失。
|
||||
5. 点击任意资源后“新增资源 / 按依赖 / 按类型 / 复位”仍保持挂载和原状态;详情为非模态独立卡片,背景画板 viewport、搜索、排序、卡片和连线不卸载、不重置。
|
||||
6. 图片资源点击“编辑资源”后恢复唯一活动 refine 草稿,入口图进入默认参考;点击任意图片直接在其下方显示快速编辑卡,图片上方不再出现浮动工具栏。
|
||||
7. 快速编辑卡底部显示适用的删除、设为最终图和修改动作。拖动图片不误触发卡片;删除只影响草稿图层;候选设为最终图后保持原 asset ID、更新正式 PNG,草稿及其它候选仍可继续编辑。
|
||||
8. 点击修改后立即创建避让现有内容的生成占位和任务侧栏记录;成功图在同一落点出现,失败只影响该占位和任务,重启后恢复原 generation/operation,不重新扣费提交。
|
||||
9. refine 请求通过严格 DTO 合同且不携带非法 `assetKind`;参数、来源类型、余额、鉴权和对账错误显示不同的安全错误码。
|
||||
10. 1:1、16:9、9:16、2:3、3:2 和极端横竖图片按真实比例显示并受边界限制;卡片不重叠,当前栏目 extent、复位视口和依赖连线端点正确。非图片资源尺寸不变。
|
||||
11. GIF、AVIF、BMP、SVG 等不满足栅格快速编辑能力的资源继续使用能力模型中的降级编辑路径,不伪装成可提交 PNG 候选。
|
||||
12. 历史 `game-background` 等私有类型来源不得直接作为 refine 主来源;首次快速编辑完成 canonical 登记,后续同源请求复用缓存,并实际到达 `candidate-ready`。
|
||||
13. 点击导入可以原生多选图片;成功批次只推进一次草稿 revision、立即出现在当前视口并被选中,取消和失败不改变草稿且不遗留部分导入。
|
||||
14. 明确失败任务可从占位或任务侧栏归档,重启后不恢复;`reconciliation-required` 不显示删除能力。任务列表具有与现役美术画布一致的独立按钮、双 Tab、状态图标与时间/进度信息;折叠后只保留按钮且不丢任务数据,新建任务自动展开。
|
||||
15. 画布带运行中 generation 重新进入时,在远端恢复完成前已经可选图、平移和继续编辑;后台恢复继续推进原 operation,不重复 POST、不重新扣费。阻断性提交/恢复失败的遮罩覆盖整个 Tauri 窗口。
|
||||
16. 已登记 refine 资产的候选设为最终图后保持原 `assetId`、原 `source.resourceId` 与新正式 PNG 路径;`revision-installed` 中断后重新加载可前向收敛为 committed。
|
||||
|
||||
## 验证命令
|
||||
|
||||
|
||||
@@ -471,6 +471,7 @@ export interface GameCreationAppAssetManifestEntry {
|
||||
export const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION =
|
||||
'game-creator-resource-layout.v1' as const;
|
||||
export const GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION = 9_007_199_254_740_991;
|
||||
export const GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE = -1_000_000;
|
||||
export const GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE = 1_000_000;
|
||||
|
||||
export function isSafeProjectResourceCanvasLayoutRevision(
|
||||
@@ -490,7 +491,7 @@ export function isSafeProjectResourceCanvasCoordinate(
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value >= 0 &&
|
||||
value >= GAME_CREATION_RESOURCE_LAYOUT_MIN_COORDINATE &&
|
||||
value <= GAME_CREATION_RESOURCE_LAYOUT_MAX_COORDINATE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -543,8 +543,8 @@ pub enum ProjectResourceCanvasSection {
|
||||
pub struct ProjectResourceCanvasPosition {
|
||||
pub resource_id: String,
|
||||
pub section: ProjectResourceCanvasSection,
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub x: i32,
|
||||
pub y: i32,
|
||||
pub manually_placed: bool,
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user