diff --git a/headless/README.md b/headless/README.md index 03ff446..745635b 100644 --- a/headless/README.md +++ b/headless/README.md @@ -373,3 +373,13 @@ byte ownership, elements, history, ledger and job progress publish together; rejected transactions restore prior temporary bytes and leave no binding. Existing owned resources must match immutable stored metadata; no fake upload is created. Cached bytes never replace fresh permission resolution. Recovery remains v15. + +`projectedConnectionPaintOverflow` adds conservative scene-space bounds for cubic +control hulls plus half stroke. It uses the same geometry protocol and connector +adapters as hit testing, with native handles for custom record renderers. Default +work limits are 4096 edges and 4096 total cubic segments. Missing geometry, +invalid values and exhausted budgets remain unknown and suspend native culling; +they are not treated as empty paint. CanvasView queries a separate overflow grid +against its viewport. This currently retains the whole view when needed; it is +not selective edge mounting, exact curve bounds, overlay indexing or dirty-region +rendering. Custom renderers must supply a compatible geometry protocol. diff --git a/src/components/canvas/headless-graph-canvas.tsx b/src/components/canvas/headless-graph-canvas.tsx index 184a301..3c751af 100644 --- a/src/components/canvas/headless-graph-canvas.tsx +++ b/src/components/canvas/headless-graph-canvas.tsx @@ -10,6 +10,8 @@ import { defaultCanvasRenderers } from "./canvas-renderers"; import type { Data, ElementRecord, Geometry, Id, Json, Result } from "@/lib/canvas-core/types"; import { equal } from "@/lib/canvas-core/values"; import {createGeometryProjectionCache} from "@/lib/canvas-bindings/geometry-cache"; +import {createPaintIndex} from "@/lib/canvas-bindings/paint-index"; +import {projectedConnectionPaintOverflow} from "@/lib/canvas-bindings/connection-paint"; import {createProjectedPaintIndex} from "@/lib/canvas-bindings/projected-paint"; import {createSessionFrameSource} from "@/lib/canvas-bindings/session-frames"; import {routeCanvasKey} from "@/lib/canvas-bindings/input-router"; @@ -78,6 +80,9 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const paint=useMemo(()=>paintCache.sync(projection,engine.editor.registry),[paintCache,projection,engine]); const viewport=useViewport(),surfaceWidth=useStore(state=>state.width),surfaceHeight=useStore(state=>state.height); const paintVisible=paint.unknown.length>0||!surfaceWidth||!surfaceHeight||paint.query({x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}).size>0; + const nativeNodes=useStore(state=>state.nodes); + const connectionPaint=useMemo(()=>{const result=projectedConnectionPaintOverflow(projection,engine.editor.registry,{anchor:(node,handle,end,edge)=>{const measured=flow.getInternalNode(node.id);return node.data.__headlessRecord||edge.type==='unsupported'?measuredConnector(measured,handle,end):compatConnector(node,handle,end==='source'?'right':'left');}}),index=createPaintIndex();index.sync(result.regions);return{index,unknown:result.unknown};},[projection,engine,flow,nativeNodes]); + const connectionPaintVisible=connectionPaint.unknown.length>0||!surfaceWidth||!surfaceHeight||connectionPaint.index.query({x:-viewport.x/viewport.zoom,y:-viewport.y/viewport.zoom,width:surfaceWidth/viewport.zoom,height:surfaceHeight/viewport.zoom}).size>0; const nodeTypes=useMemo(()=>({...renderers.nodeTypes,unsupported:UnsupportedNode}),[renderers]),edgeTypes=useMemo(()=>({...renderers.edgeTypes,unsupported:UnsupportedEdge}),[renderers]); const current = useRef({ root, projection, view, engine, sessions }); current.current = { root, projection, view, engine, sessions }; const report = useCallback((result: Result | { enabled: boolean; code?: string }) => { @@ -163,7 +168,7 @@ function CanvasView({ engine, board, viewId, renderers,fitLoading=true }: { engi const modes = view.modes; // Native culling keeps the full graph in React Flow. Retain every mounted editor/lease // while an interaction is active so viewport changes cannot discard IME or capture. - const cullVisible = !paintVisible && !editorFocused && modes.size === 0 && sessions.gesture?.viewId !== viewId && sessions.connectionGesture?.viewId !== viewId; + const cullVisible = !connectionPaintVisible && !paintVisible && !editorFocused && modes.size === 0 && sessions.gesture?.viewId !== viewId && sessions.connectionGesture?.viewId !== viewId; const selected = view.selection.size === 1 ? root.document.elements.get([...view.selection][0]) : undefined; const selectedResource=selected?.structure==='node'&&engine.editor.registry.resolve(selected)==='known'&&engine.editor.registry.supports(selected,'geometry.resize')&&typeof selected.data.assetId==='string'&&engine.editor.registry.components.get(selected.componentId)?.resourceFields?.includes('assetId')?root.runtime.resources.get(selected.data.assetId):undefined; const canFitImage=!!selectedResource&&(selectedResource.image?.version===1&&selectedResource.image.status==='ready'||!!selectedResource.stored)&&!sessions.gesture; diff --git a/src/lib/canvas-bindings/connection-paint.ts b/src/lib/canvas-bindings/connection-paint.ts new file mode 100644 index 0000000..655e1f4 --- /dev/null +++ b/src/lib/canvas-bindings/connection-paint.ts @@ -0,0 +1,28 @@ +import type {Registry} from '../canvas-core/types.ts'; +import type {projectCanvas} from './projection.ts'; +import type {HitServices} from './hit-query.ts'; +import type {PaintRegion} from './paint-index.ts'; +import {compatConnector} from './connectors.ts'; +/** Conservative cubic control hull + half stroke, in the displayed scene space. + * Missing/custom geometry stays unknown so a host cannot interpret it as invisible. */ +export function projectedConnectionPaintOverflow(projection:ReturnType,registry:Registry,services:HitServices={},maxEdges=4096,maxSegments=4096){ + const regions:PaintRegion[]=[],unknown:string[]=[],nodes=new Map(projection.nodes.map(node=>[node.id,node])); + if(![maxEdges,maxSegments].every(n=>Number.isSafeInteger(n)&&n>0&&n<=1000000))throw Error('invalid_paint_budget'); + let remainingSegments=maxSegments; + for(let index=0;index=maxEdges){unknown.push('paint_budget');break;}const edge=projection.edges[index]; + try{const record=projection.document.elements.get(edge.id),source=nodes.get(edge.source),target=nodes.get(edge.target);if(!record||record.structure!=='connection'||!source||!target)throw Error(); + const anchor=(node:typeof source,handle:string|null|undefined,end:'source'|'target')=>services.anchor?services.anchor(node,handle,end,edge):node.data.__headlessRecord?undefined:compatConnector(node,handle,end==='source'?'right':'left'); + const from=anchor(source,edge.sourceHandle,'source'),to=anchor(target,edge.targetHandle,'target'),transform=projection.transforms.get(record.ownerCanvasId);if(!from||!to||!transform)throw Error(); + const protocol=edge.type!=='unsupported'&®istry.resolve(record)==='known'?registry.components.get(record.componentId)?.geometry:undefined; + const segments=services.edgeGeometry?.(edge,from,to)??(protocol?.kind==='connection'?protocol.segments({element:record,source:from,target:to,displayPath:edge.data?.__headlessRecord?undefined:edge.data?.path??null,transform}):undefined),stroke=services.strokeWidth?.(edge)??(protocol?.kind==='connection'?protocol.strokeWidth({element:record,transform}):undefined); + if(!segments||!Array.isArray(segments)||segments.length>maxSegments||typeof stroke!=='number'||!Number.isFinite(stroke)||stroke<0)throw Error();if(!segments.length)continue;if(segments.length>remainingSegments){unknown.push('paint_budget');break;}remainingSegments-=segments.length; + let left=Infinity,top=Infinity,right=-Infinity,bottom=-Infinity; + for(const segment of segments){if(!Array.isArray(segment)||segment.length!==4)throw Error();for(const point of segment){if(!point||!Number.isFinite(point.x)||!Number.isFinite(point.y))throw Error();left=Math.min(left,point.x);top=Math.min(top,point.y);right=Math.max(right,point.x);bottom=Math.max(bottom,point.y);}} + const half=stroke/2,x=left-half,y=top-half,width=right-left+stroke,height=bottom-top+stroke;if(![x,y,width,height,x+width,y+height].every(Number.isFinite))throw Error(); + const native={left:Math.min(source.position.x,target.position.x),top:Math.min(source.position.y,target.position.y),right:Math.max(source.position.x+source.width!,target.position.x+target.width!),bottom:Math.max(source.position.y+source.height!,target.position.y+target.height!)}; + if(!Object.values(native).every(Number.isFinite))throw Error();if(xnative.right||y+height>native.bottom)regions.push({id:edge.id,bounds:{x,y,width,height}}); + }catch{unknown.push(edge.id);} + } + return{regions,unknown}; +} diff --git a/src/lib/canvas-bindings/index.ts b/src/lib/canvas-bindings/index.ts index f119ddd..8e4d803 100644 --- a/src/lib/canvas-bindings/index.ts +++ b/src/lib/canvas-bindings/index.ts @@ -18,3 +18,4 @@ export {createSessionFrameSource} from './session-frames.ts'; export {createPaintIndex} from './paint-index.ts'; export type {PaintRegion} from './paint-index.ts'; export {projectedPaintOverflow,createProjectedPaintIndex} from './projected-paint.ts'; +export {projectedConnectionPaintOverflow} from './connection-paint.ts'; diff --git a/tests/headless/connection-paint.test.mjs b/tests/headless/connection-paint.test.mjs new file mode 100644 index 0000000..51a6429 --- /dev/null +++ b/tests/headless/connection-paint.test.mjs @@ -0,0 +1,10 @@ +import {test} from 'node:test';import assert from 'node:assert/strict'; +import {createDocument,createRegistry,builtinComponents} from '../../src/lib/canvas-core/index.ts'; +import {projectCanvas} from '../../src/lib/canvas-bindings/projection.ts'; +import {projectedConnectionPaintOverflow} from '../../src/lib/canvas-bindings/connection-paint.ts'; +import {createPaintIndex} from '../../src/lib/canvas-bindings/paint-index.ts'; +const registry=createRegistry(builtinComponents()),node=(id,x)=>({id,incarnation:1,ownerCanvasId:'p',componentId:'text',dataVersion:1,structure:'node',data:{title:id,text:''},geometry:{x,y:500,width:100,height:100,rotation:0}}),edge=(id='edge')=>({id,incarnation:1,ownerCanvasId:'p',componentId:'connection',dataVersion:1,structure:'connection',data:{},source:{elementId:'a'},target:{elementId:'b'},path:null}); +const draw=(edges=[edge()])=>projectCanvas(createDocument('p',[{id:'p',parent:null}],[node('a',0),node('b',500),...edges]),'p',registry,new Set(),()=>undefined); +test('bowed cubic paint is indexed outside endpoint rectangles including stroke while document bounds remain unchanged',()=>{const projection=draw(),result=projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:100,y:-500},{x:500,y:-500},{x:500,y:550}]],strokeWidth:()=>10});assert.deepEqual(result.unknown,[]);assert.deepEqual(result.regions[0].bounds,{x:95,y:-505,width:410,height:1060});const index=createPaintIndex();index.sync(result.regions);assert.ok(index.query({x:250,y:0,width:10,height:10}).has('edge'));assert.equal(projection.document.elements.get('a').geometry.y,500);}); +test('straight contained paint needs no overflow; missing anchors and invalid geometry stay unknown',()=>{const projection=draw();assert.equal(projectedConnectionPaintOverflow(projection,registry,{edgeGeometry:()=>[[{x:100,y:550},{x:200,y:550},{x:400,y:550},{x:500,y:550}]],strokeWidth:()=>2}).regions.length,0);for(const services of [{anchor:()=>undefined},{edgeGeometry:()=>[[{x:NaN,y:0}]],strokeWidth:()=>1},{strokeWidth:()=>Infinity}])assert.deepEqual(projectedConnectionPaintOverflow(projection,registry,services).unknown,['edge']);}); +test('work budgets return unknown instead of treating omitted edges or excessive segments as invisible',()=>{const projection=draw([edge('one'),edge('two')]);assert.ok(projectedConnectionPaintOverflow(projection,registry,{},1).unknown.includes('paint_budget'));assert.deepEqual(projectedConnectionPaintOverflow(draw(),registry,{edgeGeometry:()=>[[],[]]},4096,1).unknown,['edge']);assert.throws(()=>projectedConnectionPaintOverflow(draw(),registry,{},0));}); diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index 6db3156..8c2d517 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -430,6 +430,12 @@ try { + const bowRoot=id(75);versions.set(bowRoot,1);boards.push({id:bowRoot,workspace_id:workspace,kind:'board',version:1,title:'Bowed paint board'});documents.set(bowRoot,createDocument(bowRoot,[{id:bowRoot,parent:null}],[{...node('bow-source',bowRoot,0),geometry:{x:0,y:2000,width:260,height:200,rotation:0}},{...node('bow-target',bowRoot,600),geometry:{x:600,y:2000,width:260,height:200,rotation:0}},{id:'bow-edge',incarnation:1,ownerCanvasId:bowRoot,structure:'connection',componentId:'connection',dataVersion:1,data:{},source:{elementId:'bow-source'},target:{elementId:'bow-target'},path:{type:'bezier-chain',knots:[{id:'bow-knot',mode:'corner',position:{x:400,y:0},in:{x:-100,y:0},out:{x:100,y:0}}]}}])); + await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${bowRoot}`});await until(`!!document.querySelector('.react-flow__edge[data-id="bow-edge"] .react-flow__edge-path')`,'bowed curve fixture did not open'); + await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),r=pane.getBoundingClientRect(),m=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);pane.dispatchEvent(new WheelEvent('wheel',{deltaX:(400*m.a+m.e-r.width/2)/.5,deltaY:(m.f-r.height/2)/.5,bubbles:true,cancelable:true}));})()`); + await until(`(()=>{const viewport=document.querySelector('.react-flow__pane').getBoundingClientRect(),nodes=[...document.querySelectorAll('.react-flow__node')];return nodes.length===2&&nodes.every(node=>{const r=node.getBoundingClientRect();return r.bottomviewport.bottom;})&&!!document.querySelector('.react-flow__edge[data-id="bow-edge"] .react-flow__edge-path');})()`,'offscreen endpoints caused visible bowed paint to unmount'); + assert.equal(documents.get(bowRoot).elements.size,3);console.log('PASS bowed curve paint remains mounted with both endpoint rectangles outside the viewport'); + await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`); await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view'); assert.ok(calls.some(call=>call.path.endsWith('/canvas/model')&&call.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'));