From aec7e227f5df30fff2894ae164a451bb05b48c63 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:29:57 +0000 Subject: [PATCH 1/3] Contraption builder: fix invisible new parts, add keyboard UX, rect-aware clamping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported bug — placed parts often not showing until Run — is the acceleratedRendering compositor: a control created while the screen sits idle can stay invisible until the compositor rebuilds its scene, and idle Build mode never forced a rebuild (Run's continuous redraws did, which is why parts appeared the moment a contraption ran). Fixes: - tagPart gives EVERY part its own dynamic GPU layer, not just the physics-dynamic kinds — anchors, plates, sensors, fans, magnets, lasers, goals and terrain were exactly the kinds that vanished. Joint markers, signal wires, the freehand sketch line, thruster flames and shock rings get the same treatment at creation. - renderBuild ends with a flag-guarded one-shot compositor nudge (acceleratedRendering off/on) whenever something was created since the last render; the freehand sketch, the image-library rows and a replayed onboarding tour nudge directly because they appear mid-gesture. - drawWires now reuses and re-points persistent marker graphics instead of deleting and recreating every wire on every build redraw (which would also have re-armed the nudge each time). UX hardening on top: - Keyboard layer: Delete/Backspace removes the selected part or joint; Escape closes overlays, then cancels pending joints/wires, then clears the selection; arrow keys nudge the selection 1px (Shift 10px) with the same group-follow, clamping and body re-seat as a mouse drag. - clampPartToArena keeps a part's whole rect on the stage at placement, for duplicate/multiply copies, during drags and after nudges — only the centre was clamped before, so wide parts could be parked half over the palette/inspector or under the ground bar. Verified statically (all gates pass); needs an OXT pass to confirm the compositor behaviour on a live engine. https://claude.ai/code/session_013hrEhbLW5bVDwUSmG7hYkJ --- CHANGELOG.md | 26 +++ ...box2dxt-contraption-builder.livecodescript | 200 +++++++++++++++++- 2 files changed, 215 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f63c56..025eef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ The native shim's ABI is tracked separately by `b2Version()` (currently `4`). ### Added +- **Keyboard support in the contraption builder.** **Delete**/**Backspace** + removes the selected part or joint (Build mode, with full joint/wire + cleanup); **Escape** walks outward — closes an open overlay, then cancels a + half-made joint or wire, then clears the selection; **arrow keys** nudge the + selected part 1 px (Shift: 10 px) with the same group-follow, arena clamping + and body re-seat as a mouse drag. - **Generation-tagged handles (C shim).** Every handle now packs an 11-bit generation above its 20-bit table slot, bumped each time the slot is freed. A stale handle therefore stays a harmless no-op even after its slot is @@ -388,6 +394,26 @@ The native shim's ABI is tracked separately by `b2Version()` (currently `4`). ### Fixed +- **Placed parts now show up immediately, not "only after I press Run" + (contraption builder).** Under `acceleratedRendering`, a control created + while the screen sits idle can stay invisible until the compositor rebuilds + its scene — and idle Build mode never forced that rebuild (Run's continuous + redraws did, which is why parts "appeared" then). Every part now composites + on its own dynamic GPU layer the moment it is tagged (previously only the + physics-dynamic kinds did, so anchors, plates, sensors, fans, magnets, + lasers, goals and terrain were exactly the kinds that vanished), the same + applies to joint markers, signal wires, the freehand-terrain sketch line, + thruster flames and shock rings, and `renderBuild` gives the compositor a + one-shot nudge whenever something new was created — including the image + library's rows and a replayed onboarding tour. +- **Parts can no longer be parked overlapping the chrome (contraption + builder).** Placement, duplicate/multiply copies, build-mode drags and arrow + nudges keep the part's whole rect inside the arena (previously only its + centre was clamped, so a wide platform could hang halfway over the palette + or under the ground bar). The laser keeps its emitter-based dragging. +- **Signal wires no longer churn the renderer (contraption builder).** + `drawWires` reuses its marker graphics and re-points them instead of + deleting and recreating every wire on every build-mode redraw. - **Geometry getters no longer leak the previous shape's values.** The circle/capsule/segment read-back stash zeroes on a failed update (stale handle or wrong shape type), so `b2ShapeCircleRadius()` & co. report `0` diff --git a/examples/box2dxt-contraption-builder.livecodescript b/examples/box2dxt-contraption-builder.livecodescript index 99c390b..cfa8deb 100644 --- a/examples/box2dxt-contraption-builder.livecodescript +++ b/examples/box2dxt-contraption-builder.livecodescript @@ -2032,6 +2032,7 @@ local gPendCtrl, gPendX, gPendY local gMotorOn, gMotorSpeed, gBouncy, gMotorsRunning local gDragCtrl, gDragMode, gDragMoved local gDrawPts, gDrawCtrl -- freehand-terrain points + its preview graphic +local gNewControls -- true when controls were created since the last compositor nudge local gParts local gJN local gJKind, gJHandle, gJCa, gJCb @@ -2266,8 +2267,23 @@ on renderBuild updateLasers drawWires unlock screen + if gNewControls is true then nudgeCompositor end renderBuild +-- Under acceleratedRendering, a control created while the screen sits idle can +-- stay INVISIBLE until the compositor rebuilds its scene — the engine-level +-- cause of "my placed part only appears when I press Run" (Run's continuous +-- redraws force rebuilds; idle Build mode never does). Toggling +-- acceleratedRendering forces that rebuild now. Called flag-guarded from +-- renderBuild so it runs once per created-something gesture, never per frame. +on nudgeCompositor + put false into gNewControls + if the acceleratedRendering of this stack then + set the acceleratedRendering of this stack to false + set the acceleratedRendering of this stack to true + end if +end nudgeCompositor + -- ===================================================================== -- Arena (walls + a framed play field, mirrors the showcase demo) -- ===================================================================== @@ -2938,7 +2954,7 @@ on mouseMove pX, pY exit mouseMove end if if gDragMode is "build" and gDragCtrl is not empty then - local tCx, tCy, tOld, tDx, tDy, tGroup, tP + local tCx, tCy, tOld, tDx, tDy, tGroup, tP, tHw, tHh put max(gArenaL, min(gArenaR, pX)) into tCx put max(gArenaT, min(gArenaB, pY)) into tCy if the uKind of gDragCtrl is "laser" then -- drag a laser by its emitter, not the beam midpoint @@ -2947,6 +2963,12 @@ on mouseMove pX, pY put true into gDragMoved exit mouseMove end if + -- keep the whole rect on the stage, not just its centre, so a wide or + -- tall part can't be parked half-over the side panels or the ground bar + put min((the width of gDragCtrl) div 2, (gArenaR - gArenaL) div 2) into tHw + put min((the height of gDragCtrl) div 2, (gArenaB - gArenaT) div 2) into tHh + put max(gArenaL + tHw, min(gArenaR - tHw, tCx)) into tCx + put max(gArenaT + tHh, min(gArenaB - tHh, tCy)) into tCy put the loc of gDragCtrl into tOld put round(tCx) - item 1 of tOld into tDx -- how far the grabbed part moved put round(tCy) - item 2 of tOld into tDy @@ -3006,6 +3028,25 @@ on reseatDragged pCtrl b2kMoveTo pCtrl, item 1 of tLoc, item 2 of tLoc, b2kAngle(pCtrl) end reseatDragged +-- Pull a part's whole RECT inside the arena (placement clamps only the click +-- point, so a part dropped near an edge could overhang the palette, the +-- inspector or the ground bar). Re-seats the body if the graphic moved. The +-- laser is exempt: its graphic is the beam line, whose bbox is not its footprint. +on clampPartToArena pCtrl + if pCtrl is empty then exit clampPartToArena + if the uKind of pCtrl is "laser" then exit clampPartToArena + local tLoc, tHw, tHh, tCx, tCy + set the itemDelimiter to comma + put the loc of pCtrl into tLoc + put min((the width of pCtrl) div 2, (gArenaR - gArenaL) div 2) into tHw + put min((the height of pCtrl) div 2, (gArenaB - gArenaT) div 2) into tHh + put max(gArenaL + tHw, min(gArenaR - tHw, item 1 of tLoc)) into tCx + put max(gArenaT + tHh, min(gArenaB - tHh, item 2 of tLoc)) into tCy + if (tCx & "," & tCy) is tLoc then exit clampPartToArena + set the loc of pCtrl to tCx & "," & tCy + reseatDragged pCtrl +end clampPartToArena + -- The engine sends mouseRelease (not mouseUp) when the pointer is let go over a -- different control than the one that got mouseDown — e.g. dragging a part toward -- the right edge and releasing over the inspector. Route it through the same @@ -3014,6 +3055,115 @@ on mouseRelease endDrag end mouseRelease +-- ===================================================================== +-- Keyboard: Delete removes the selection, Escape cancels/closes, arrows +-- nudge the selected part. These are stack-level messages, so they only +-- arrive when no dialog or editable field has keyboard focus. +-- ===================================================================== +on deleteKey + deleteSelection +end deleteKey + +on backspaceKey + deleteSelection +end backspaceKey + +on deleteSelection + if gMode is not "build" then + put "Switch back to Build before deleting parts." into gStatus + updateHud + exit deleteSelection + end if + if gSelJoint is not empty then + removeJointAt gSelJoint + renderBuild + put "Deleted the selected joint." into gStatus + else if gSelPart is not empty then + deletePart gSelPart + renderBuild + else + put "Nothing selected — click a part (or a joint marker) first." into gStatus + end if + updateHud +end deleteSelection + +-- Escape walks outward: close an open overlay first, then cancel a half-made +-- joint/wire, then clear the selection. One press per layer, like any editor. +on escapeKey + if there is a graphic "ui_onb_scrim" then + dismissOnboarding + else if there is a graphic "ui_recipescrim" and the visible of graphic "ui_recipescrim" then + hideRecipesMenu + else if there is a graphic "ui_filterscrim" and the visible of graphic "ui_filterscrim" then + hideFilterPanel + else if there is a graphic "ui_worldscrim" and the visible of graphic "ui_worldscrim" then + hideWorldPanel + else if there is a graphic "ui_imgscrim" and the visible of graphic "ui_imgscrim" then + hideImagePanel + else if gWireSrc is not empty then + put empty into gWireSrc + put "Wire cancelled." into gStatus + else if gPendCtrl is not empty then + resetPend + put "Joint cancelled." into gStatus + else + deselectJoint + deselectPart + put "Selection cleared." into gStatus + end if + updateHud +end escapeKey + +-- Arrow keys nudge the selected part 1 px (Shift: 10 px) in Build mode, with +-- the same group-follow, clamping and body re-seat as a mouse drag. +on arrowKey pKey + local tDx, tDy, tStep, tGroup, tP, tPt + if gMode is not "build" or gSelPart is empty then pass arrowKey + if gSelPart is not among the lines of gParts then pass arrowKey + put 1 into tStep + if the shiftKey is down then put 10 into tStep + put 0 into tDx + put 0 into tDy + switch pKey + case "left" + put - tStep into tDx + break + case "right" + put tStep into tDx + break + case "up" + put - tStep into tDy + break + case "down" + put tStep into tDy + break + default + pass arrowKey + end switch + set the itemDelimiter to comma + if the uKind of gSelPart is "laser" then -- nudge the emitter, like a drag does + put line 1 of the points of gSelPart into tPt + set the points of gSelPart to ((item 1 of tPt) + tDx) & "," & ((item 2 of tPt) + tDy) \ + & cr & (line 2 of the points of gSelPart) + drawLaserBeam gSelPart + exit arrowKey + end if + set the loc of gSelPart to ((item 1 of the loc of gSelPart) + tDx) & "," & ((item 2 of the loc of gSelPart) + tDy) + put the uGroup of gSelPart into tGroup + if tGroup is not empty then -- a grouped assembly moves as one + repeat for each line tP in gParts + if tP is empty or tP is gSelPart then next repeat + if the uGroup of tP is tGroup then + set the loc of tP to ((item 1 of the loc of tP) + tDx) & "," & ((item 2 of the loc of tP) + tDy) + reseatDragged tP + end if + end repeat + end if + clampPartToArena gSelPart + reseatDragged gSelPart + renderBuild +end arrowKey + -- Shift-click during Run kicks the dynamic body under the pointer: a sharp, -- mass-aware upward impulse with a little sideways scatter, plus a tumble. Shows -- off the one-shot b2kImpulse / b2kAngularImpulse force handlers. @@ -3402,6 +3552,7 @@ function placePart pX, pY return empty end switch if tCtrl is not empty then + clampPartToArena tCtrl -- an edge click must not seed a part overlapping the chrome renderBuild -- draw the new part right away (no Run needed) put "Placed a " & friendlyKind(gTool) & ". Drag to move it; the inspector on the right explains it." into gStatus selectPart tCtrl @@ -3642,10 +3793,13 @@ on beginDrawTerrain pX, pY put "cb_terrain_" & the milliseconds & "_" & random(99999) into gDrawCtrl create graphic gDrawCtrl put the long id of graphic gDrawCtrl into gDrawCtrl + set the layerMode of gDrawCtrl to "dynamic" -- the live sketch grows every mouseMove set the style of gDrawCtrl to "line" set the lineSize of gDrawCtrl to kDrawLineSize set the foregroundColor of gDrawCtrl to "150,120,84" set the points of gDrawCtrl to gDrawPts + put true into gNewControls + nudgeCompositor -- mid-gesture: the sketch must appear NOW, before any renderBuild put "Draw the ground — release to set it." into gStatus end beginDrawTerrain @@ -3811,6 +3965,13 @@ on tagPart pCtrl, pKind, pColor, pFile put pCtrl & cr after gParts put true into gDynDirty -- the dynamic-part cache must be rebuilt registerKind pCtrl, pKind + -- EVERY part composites on its own GPU layer, not just the physics-dynamic + -- kinds: parts move regardless (a build-mode drag, the inspector's resize + -- steppers, the selection recolour), and — critically — a control born into + -- the compositor's cached static scene often stays invisible until a full + -- recomposite. A dynamic layer renders fresh, so new parts show immediately. + set the layerMode of pCtrl to "dynamic" + put true into gNewControls -- renderBuild nudges the compositor once if gBouncy and kindIsDynamic(pKind) then b2kSetBounce pCtrl, kBounceOn set the uBounce of pCtrl to kBounceOn @@ -3827,7 +3988,6 @@ on tagPart pCtrl, pKind, pColor, pFile set the uGravity of pCtrl to 1 set the uToughness of pCtrl to kToughnessDefault set the uFixedRot of pCtrl to false - set the layerMode of pCtrl to "dynamic" -- moving bodies composite on their own GPU layer (no smear/clip artifacts) b2kWake pCtrl -- so the first sync draws it end if end tagPart @@ -4333,6 +4493,8 @@ on recordJoint pKind, pJoint, pA, pB, pX, pY, pVis put empty into gJEnd[gJN] put "cb_joint_" & gJN into tG create graphic tG + set the layerMode of graphic tG to "dynamic" -- re-pointed every frame; also shows at once in idle Build mode + put true into gNewControls if pVis is "none" then set the visible of graphic tG to false -- internal structural pin: no marker else if pVis is "dot" then @@ -4563,6 +4725,7 @@ function copyPart pCtrl, pX, pY end if applyPartSpecial tNew, partSpecial(pCtrl) set the uGroup of tNew to empty + clampPartToArena tNew -- copies offset toward an edge stay on the stage return tNew end copyPart @@ -5531,6 +5694,7 @@ on drawThrusterFlame pT, pRad, pPow put "cb_flame_" & (the uPartId of pT) into tName if there is no graphic tName then create graphic tName + set the layerMode of graphic tName to "dynamic" -- follows its thruster every frame set the style of graphic tName to "oval" set the filled of graphic tName to true set the lineSize of graphic tName to 0 @@ -5960,6 +6124,7 @@ on spawnRing pX, pY put pY into gRingY[gRingN] put 0 into gRingF[gRingN] create graphic ("cb_ring_" & gRingN) + set the layerMode of graphic ("cb_ring_" & gRingN) to "dynamic" -- expands every frame set the style of graphic ("cb_ring_" & gRingN) to "oval" set the filled of graphic ("cb_ring_" & gRingN) to false set the lineSize of graphic ("cb_ring_" & gRingN) to 3 @@ -6148,9 +6313,7 @@ end fireTarget -- Redraw the signal wires as amber connector lines between parts (build mode). on drawWires - deleteByPrefix "cb_wire_" - if gWires is empty then exit drawWires - local tEdge, tSrc, tTgt, tN, tName, tRef, tx0, ty0, tx1, ty1 + local tEdge, tSrc, tTgt, tN, tName, tx0, ty0, tx1, ty1 put 0 into tN repeat for each line tEdge in gWires set the itemDelimiter to tab @@ -6165,12 +6328,23 @@ on drawWires put item 2 of the loc of tTgt into ty1 add 1 to tN put "cb_wire_" & tN into tName - create graphic tName - put the long id of graphic tName into tRef - set the style of tRef to "line" - set the lineSize of tRef to 2 - set the foregroundColor of tRef to "232,200,72" - set the points of tRef to round(tx0) & "," & round(ty0) & cr & round(tx1) & "," & round(ty1) + -- Reuse the marker if it already exists and only restyle on creation: + -- renderBuild calls this constantly, and recreating the graphics each + -- time both churns the compositor and would re-trigger its nudge. + if there is no graphic tName then + create graphic tName + set the layerMode of graphic tName to "dynamic" -- shows at once in idle Build mode + set the style of graphic tName to "line" + set the lineSize of graphic tName to 2 + set the foregroundColor of graphic tName to "232,200,72" + put true into gNewControls + end if + set the points of graphic tName to round(tx0) & "," & round(ty0) & cr & round(tx1) & "," & round(ty1) + end repeat + repeat forever -- drop leftover markers past the live count + add 1 to tN + if there is no graphic ("cb_wire_" & tN) then exit repeat + delete graphic ("cb_wire_" & tN) end repeat end drawWires @@ -6441,6 +6615,8 @@ on refreshImagePanel set the uImgMenu of button ("ui_act_imgremove_" & tI) to true end repeat setImageVisible true + put true into gNewControls + nudgeCompositor -- the rows are created while the panel opens; they must show now end refreshImagePanel -- Place an instance of a library image at the top of the stage, then select it. @@ -9628,6 +9804,8 @@ on startOnboarding put 1 into gOnbStep buildOnboardingOverlay showOnboardingStep + put true into gNewControls + nudgeCompositor -- the tour can be replayed mid-session; the fresh overlay must show now end startOnboarding on buildOnboardingOverlay From 4b44ee7a0a8ce779374c7584ed62c075fd28f945 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:48:58 +0000 Subject: [PATCH 2/3] Kit: draw bodies at attach time - the real cause of parts invisible until Run b2kAddBox / b2kAddCapsule switch a spawned graphic's style to polygon and leave its points empty until the first body sync. But b2kSync drew through the move-event fast path (b2BodiesUpdate), and Box2D only emits move events from b2World_Step - a stopped world emits none. So a box, capsule or thruster placed while the loop is stopped (the builder's Build mode) stayed a point-less, invisible polygon until Run produced the first step. Balls, polygons and terrain have complete graphics at creation, which is why the bug hit 'often' rather than always - and why it survived stack recreation. - b2kAddBox / b2kAddBall / b2kAddCapsule / b2kAddPolygon now draw the body the moment it is attached (b2kReshape already did this on its path). - The public b2kSync - the documented 'loop is stopped, redraw by hand' entry point - now full-scans via b2kSyncAllBodies instead of relying on move events that cannot exist without a step. The running loop keeps the lock-free move-event fast path (it calls b2kSyncBodies directly). Not related to the C shim or LCB binding: b2lc_bodies_update correctly reports zero events for a never-stepped world. Script-only fix; no native rebuild needed. Embedded kit copies resynced. https://claude.ai/code/session_013hrEhbLW5bVDwUSmG7hYkJ --- CHANGELOG.md | 36 ++++++++++++------- ...box2dxt-contraption-builder.livecodescript | 20 +++++++++-- examples/box2dxt-demo.livecodescript | 20 +++++++++-- src/box2dxt-kit.livecodescript | 20 +++++++++-- 4 files changed, 75 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 025eef2..4e6b7c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -394,18 +394,30 @@ The native shim's ABI is tracked separately by `b2Version()` (currently `4`). ### Fixed -- **Placed parts now show up immediately, not "only after I press Run" - (contraption builder).** Under `acceleratedRendering`, a control created - while the screen sits idle can stay invisible until the compositor rebuilds - its scene — and idle Build mode never forced that rebuild (Run's continuous - redraws did, which is why parts "appeared" then). Every part now composites - on its own dynamic GPU layer the moment it is tagged (previously only the - physics-dynamic kinds did, so anchors, plates, sensors, fans, magnets, - lasers, goals and terrain were exactly the kinds that vanished), the same - applies to joint markers, signal wires, the freehand-terrain sketch line, - thruster flames and shock rings, and `renderBuild` gives the compositor a - one-shot nudge whenever something new was created — including the image - library's rows and a replayed onboarding tour. +- **Placed parts now show up immediately, not "only after I press Run".** Two + independent causes, the first one the engine-room bug (Kit-level, so the + demo and user code are healed too): + - **The Kit never drew a freshly attached body while the loop was stopped.** + `b2kAddBox`/`b2kAddCapsule` switch a spawned graphic's style to `polygon` + and leave its points empty until the first body sync — but the sync fast + path draws only bodies reported by Box2D **move events**, and a world that + isn't stepping emits none. So a box, capsule or thruster placed in Build + mode stayed a point-less (invisible) polygon until Run produced the first + step. The attach handlers now draw the body the moment it's created, and + the public `b2kSync` (the "loop is stopped, redraw by hand" entry point) + full-scans instead of relying on move events; the running loop keeps the + fast path. Balls, polygons and terrain were never affected — their + graphics are complete at creation — which is why the bug hit "often" + rather than always. + - **Compositor staleness (contraption builder).** Under + `acceleratedRendering`, a control created while the screen sits idle can + stay invisible until the compositor rebuilds its scene. Every part now + composites on its own dynamic GPU layer the moment it is tagged + (previously only the physics-dynamic kinds did), the same applies to + joint markers, signal wires, the freehand sketch line, thruster flames + and shock rings, and `renderBuild` gives the compositor a one-shot nudge + whenever something new was created — including the image library's rows + and a replayed onboarding tour. - **Parts can no longer be parked overlapping the chrome (contraption builder).** Placement, duplicate/multiply copies, build-mode drags and arrow nudges keep the part's whole rect inside the arena (previously only its diff --git a/examples/box2dxt-contraption-builder.livecodescript b/examples/box2dxt-contraption-builder.livecodescript index cfa8deb..b19f124 100644 --- a/examples/box2dxt-contraption-builder.livecodescript +++ b/examples/box2dxt-contraption-builder.livecodescript @@ -391,6 +391,11 @@ command b2kAddBox pControl, pDynamic else put "loc" into sRender[tRef] end if + -- Draw the new body NOW. The style switch above left a "poly" graphic with + -- no points (invisible), and the move-event sync can't help: a world that + -- isn't stepping emits no move events, so a part spawned while the loop is + -- stopped would stay invisible until the first Run. Attach = visible. + b2kDrawBody tRef, tWx, tWy, 0 return tBody end b2kAddBox @@ -413,6 +418,7 @@ command b2kAddBall pControl, pDynamic else if word 1 of tRef is "image" then put "image" into sRender[tRef] else put "loc" into sRender[tRef] put tRad into sRad[tRef] + b2kDrawBody tRef, tWx, tWy, 0 -- visible at attach, even with the loop stopped return tBody end b2kAddBall @@ -460,6 +466,9 @@ command b2kAddCapsule pControl, pDynamic else put "loc" into sRender[tRef] end if + -- Same as b2kAddBox: the freshly point-less "poly" graphic must be drawn + -- here, because a stopped world never emits the move event that would. + b2kDrawBody tRef, tWx, tWy, 0 return tBody end b2kAddCapsule @@ -497,6 +506,7 @@ command b2kAddPolygon pControl, pDynamic b2kRegister tRef, tBody, (not pDynamic) put "poly" into sRender[tRef] put tLocal into sVerts[tRef] + b2kDrawBody tRef, tWx, tWy, 0 -- visible at attach, even with the loop stopped return tBody end b2kAddPolygon @@ -1754,12 +1764,16 @@ command b2kStepOnce end b2kStepOnce -- Public, standalone-safe sync: locks the screen and always unlocks, even if a --- draw throws. The loop instead holds one lock across the whole frame and calls --- the lock-free b2kSyncBodies directly, so sync + events are a single redraw. +-- draw throws. This is the "the loop is stopped, redraw by hand" entry point +-- (editors and build modes call it after spawning or moving things), so it must +-- FULL-SCAN: the move-event fast path only reports bodies a b2Step moved, and a +-- stopped world steps never — relying on it here would draw nothing at all. +-- The running loop instead holds one lock across the whole frame and calls the +-- lock-free, move-event-driven b2kSyncBodies directly. command b2kSync lock screen try - b2kSyncBodies + b2kSyncAllBodies catch tErr end try unlock screen diff --git a/examples/box2dxt-demo.livecodescript b/examples/box2dxt-demo.livecodescript index e237dfd..56b5c9c 100644 --- a/examples/box2dxt-demo.livecodescript +++ b/examples/box2dxt-demo.livecodescript @@ -381,6 +381,11 @@ command b2kAddBox pControl, pDynamic else put "loc" into sRender[tRef] end if + -- Draw the new body NOW. The style switch above left a "poly" graphic with + -- no points (invisible), and the move-event sync can't help: a world that + -- isn't stepping emits no move events, so a part spawned while the loop is + -- stopped would stay invisible until the first Run. Attach = visible. + b2kDrawBody tRef, tWx, tWy, 0 return tBody end b2kAddBox @@ -403,6 +408,7 @@ command b2kAddBall pControl, pDynamic else if word 1 of tRef is "image" then put "image" into sRender[tRef] else put "loc" into sRender[tRef] put tRad into sRad[tRef] + b2kDrawBody tRef, tWx, tWy, 0 -- visible at attach, even with the loop stopped return tBody end b2kAddBall @@ -450,6 +456,9 @@ command b2kAddCapsule pControl, pDynamic else put "loc" into sRender[tRef] end if + -- Same as b2kAddBox: the freshly point-less "poly" graphic must be drawn + -- here, because a stopped world never emits the move event that would. + b2kDrawBody tRef, tWx, tWy, 0 return tBody end b2kAddCapsule @@ -487,6 +496,7 @@ command b2kAddPolygon pControl, pDynamic b2kRegister tRef, tBody, (not pDynamic) put "poly" into sRender[tRef] put tLocal into sVerts[tRef] + b2kDrawBody tRef, tWx, tWy, 0 -- visible at attach, even with the loop stopped return tBody end b2kAddPolygon @@ -1744,12 +1754,16 @@ command b2kStepOnce end b2kStepOnce -- Public, standalone-safe sync: locks the screen and always unlocks, even if a --- draw throws. The loop instead holds one lock across the whole frame and calls --- the lock-free b2kSyncBodies directly, so sync + events are a single redraw. +-- draw throws. This is the "the loop is stopped, redraw by hand" entry point +-- (editors and build modes call it after spawning or moving things), so it must +-- FULL-SCAN: the move-event fast path only reports bodies a b2Step moved, and a +-- stopped world steps never — relying on it here would draw nothing at all. +-- The running loop instead holds one lock across the whole frame and calls the +-- lock-free, move-event-driven b2kSyncBodies directly. command b2kSync lock screen try - b2kSyncBodies + b2kSyncAllBodies catch tErr end try unlock screen diff --git a/src/box2dxt-kit.livecodescript b/src/box2dxt-kit.livecodescript index 626fa51..9b2de55 100644 --- a/src/box2dxt-kit.livecodescript +++ b/src/box2dxt-kit.livecodescript @@ -355,6 +355,11 @@ command b2kAddBox pControl, pDynamic else put "loc" into sRender[tRef] end if + -- Draw the new body NOW. The style switch above left a "poly" graphic with + -- no points (invisible), and the move-event sync can't help: a world that + -- isn't stepping emits no move events, so a part spawned while the loop is + -- stopped would stay invisible until the first Run. Attach = visible. + b2kDrawBody tRef, tWx, tWy, 0 return tBody end b2kAddBox @@ -377,6 +382,7 @@ command b2kAddBall pControl, pDynamic else if word 1 of tRef is "image" then put "image" into sRender[tRef] else put "loc" into sRender[tRef] put tRad into sRad[tRef] + b2kDrawBody tRef, tWx, tWy, 0 -- visible at attach, even with the loop stopped return tBody end b2kAddBall @@ -424,6 +430,9 @@ command b2kAddCapsule pControl, pDynamic else put "loc" into sRender[tRef] end if + -- Same as b2kAddBox: the freshly point-less "poly" graphic must be drawn + -- here, because a stopped world never emits the move event that would. + b2kDrawBody tRef, tWx, tWy, 0 return tBody end b2kAddCapsule @@ -461,6 +470,7 @@ command b2kAddPolygon pControl, pDynamic b2kRegister tRef, tBody, (not pDynamic) put "poly" into sRender[tRef] put tLocal into sVerts[tRef] + b2kDrawBody tRef, tWx, tWy, 0 -- visible at attach, even with the loop stopped return tBody end b2kAddPolygon @@ -1718,12 +1728,16 @@ command b2kStepOnce end b2kStepOnce -- Public, standalone-safe sync: locks the screen and always unlocks, even if a --- draw throws. The loop instead holds one lock across the whole frame and calls --- the lock-free b2kSyncBodies directly, so sync + events are a single redraw. +-- draw throws. This is the "the loop is stopped, redraw by hand" entry point +-- (editors and build modes call it after spawning or moving things), so it must +-- FULL-SCAN: the move-event fast path only reports bodies a b2Step moved, and a +-- stopped world steps never — relying on it here would draw nothing at all. +-- The running loop instead holds one lock across the whole frame and calls the +-- lock-free, move-event-driven b2kSyncBodies directly. command b2kSync lock screen try - b2kSyncBodies + b2kSyncAllBodies catch tErr end try unlock screen From 8b430e69cd0ffea39e4eccaf9d094d751a8678c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 00:04:49 +0000 Subject: [PATCH 3/3] Builder: freeze the view under popups; roll back the compositor experiment The recipes/world/filter/image popups glitched when opened over a running sim: dozens of GPU-layered parts kept repainting underneath, and parts and joint markers bled through patchy popup chrome. The popups now enforce modal view discipline via the shared setXVisible chokepoints: - enterModalView pauses the loop (only if it was actually running; a user pause is left alone) and turns acceleratedRendering off, so the popup composites the classic cache-free way over a still scene; - exitModalView restores both exactly as found, and startCB clears the modal state so a stack closed with a popup open reopens clean. With the placement bug fixed at its root (attach-time draw + full-scan b2kSync, previous commit), the blanket every-part-on-its-own-GPU-layer change and the acceleratedRendering nudge from the first attempt are no longer needed and are rolled back to the original proven scope: dynamic layers for physics-moving bodies plus the per-frame overlays (joint markers, wires, sketch line, flames, rings). Keeps texture pressure flat on large contraptions and removes mid-session accelerated-rendering toggling as a variable. https://claude.ai/code/session_013hrEhbLW5bVDwUSmG7hYkJ --- CHANGELOG.md | 22 +++--- ...box2dxt-contraption-builder.livecodescript | 74 +++++++++++-------- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e6b7c4..f6abc02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -409,15 +409,19 @@ The native shim's ABI is tracked separately by `b2Version()` (currently `4`). fast path. Balls, polygons and terrain were never affected — their graphics are complete at creation — which is why the bug hit "often" rather than always. - - **Compositor staleness (contraption builder).** Under - `acceleratedRendering`, a control created while the screen sits idle can - stay invisible until the compositor rebuilds its scene. Every part now - composites on its own dynamic GPU layer the moment it is tagged - (previously only the physics-dynamic kinds did), the same applies to - joint markers, signal wires, the freehand sketch line, thruster flames - and shock rings, and `renderBuild` gives the compositor a one-shot nudge - whenever something new was created — including the image library's rows - and a replayed onboarding tour. + - **Per-frame overlays now composite on their own GPU layer (contraption + builder).** Joint markers, signal wires, the freehand sketch line, + thruster flames and shock rings are re-pointed constantly while running, + but lived in the compositor's cached static scene; they're now dynamic + layers like the moving bodies they follow. +- **Popups no longer glitch over a running sim (contraption builder).** + Opening Recipes / Filter / World / Images while running left dozens of + GPU-layered parts repainting underneath the popup — parts and joint markers + bled through patchy chrome. A popup now freezes the view beneath it: the + sim pauses (only if it was running) and the stack composites the classic, + cache-free way while the popup is up; both are restored exactly as found on + close. This also means a contraption can't change while the user is reading + a menu. - **Parts can no longer be parked overlapping the chrome (contraption builder).** Placement, duplicate/multiply copies, build-mode drags and arrow nudges keep the part's whole rect inside the arena (previously only its diff --git a/examples/box2dxt-contraption-builder.livecodescript b/examples/box2dxt-contraption-builder.livecodescript index b19f124..6a6fc9d 100644 --- a/examples/box2dxt-contraption-builder.livecodescript +++ b/examples/box2dxt-contraption-builder.livecodescript @@ -2046,7 +2046,7 @@ local gPendCtrl, gPendX, gPendY local gMotorOn, gMotorSpeed, gBouncy, gMotorsRunning local gDragCtrl, gDragMode, gDragMoved local gDrawPts, gDrawCtrl -- freehand-terrain points + its preview graphic -local gNewControls -- true when controls were created since the last compositor nudge +local gModalOpen, gModalPaused, gModalAccel -- popups pause the sim + drop GPU compositing beneath them local gParts local gJN local gJKind, gJHandle, gJCa, gJCb @@ -2187,6 +2187,8 @@ on startCB put "build" into gMode put "drag" into gTool put empty into gJointTool + put false into gModalOpen -- a popup can't survive a close; don't let stale state skip the next enter + put false into gModalPaused put false into gMotorOn put true into gMotorsRunning put kDefaultMotorSpeed into gMotorSpeed @@ -2281,22 +2283,38 @@ on renderBuild updateLasers drawWires unlock screen - if gNewControls is true then nudgeCompositor end renderBuild --- Under acceleratedRendering, a control created while the screen sits idle can --- stay INVISIBLE until the compositor rebuilds its scene — the engine-level --- cause of "my placed part only appears when I press Run" (Run's continuous --- redraws force rebuilds; idle Build mode never does). Toggling --- acceleratedRendering forces that rebuild now. Called flag-guarded from --- renderBuild so it runs once per created-something gesture, never per frame. -on nudgeCompositor - put false into gNewControls - if the acceleratedRendering of this stack then - set the acceleratedRendering of this stack to false - set the acceleratedRendering of this stack to true +-- ===================================================================== +-- Modal view discipline. While a popup (Recipes / Filter / World / Images) +-- is up, freeze the world beneath it: a running sim keeps every dynamic-layer +-- part repainting under the popup, which fights the compositor (parts and +-- joint markers bleed through patchy chrome) and lets the contraption change +-- while the user is looking away. Pause the loop and composite the classic, +-- cache-free way while the modal is open; restore both exactly as found. +-- ===================================================================== +on enterModalView + if gModalOpen is true then exit enterModalView + put true into gModalOpen + put b2kIsRunning() into gModalPaused -- only resume what we paused + if gModalPaused then + b2kPause + refreshPauseLabel + end if + put the acceleratedRendering of this stack into gModalAccel + set the acceleratedRendering of this stack to false +end enterModalView + +on exitModalView + if gModalOpen is not true then exit exitModalView + put false into gModalOpen + set the acceleratedRendering of this stack to gModalAccel + if gModalPaused is true then + b2kResume + refreshPauseLabel end if -end nudgeCompositor + put false into gModalPaused +end exitModalView -- ===================================================================== -- Arena (walls + a framed play field, mirrors the showcase demo) @@ -3812,8 +3830,6 @@ on beginDrawTerrain pX, pY set the lineSize of gDrawCtrl to kDrawLineSize set the foregroundColor of gDrawCtrl to "150,120,84" set the points of gDrawCtrl to gDrawPts - put true into gNewControls - nudgeCompositor -- mid-gesture: the sketch must appear NOW, before any renderBuild put "Draw the ground — release to set it." into gStatus end beginDrawTerrain @@ -3979,13 +3995,6 @@ on tagPart pCtrl, pKind, pColor, pFile put pCtrl & cr after gParts put true into gDynDirty -- the dynamic-part cache must be rebuilt registerKind pCtrl, pKind - -- EVERY part composites on its own GPU layer, not just the physics-dynamic - -- kinds: parts move regardless (a build-mode drag, the inspector's resize - -- steppers, the selection recolour), and — critically — a control born into - -- the compositor's cached static scene often stays invisible until a full - -- recomposite. A dynamic layer renders fresh, so new parts show immediately. - set the layerMode of pCtrl to "dynamic" - put true into gNewControls -- renderBuild nudges the compositor once if gBouncy and kindIsDynamic(pKind) then b2kSetBounce pCtrl, kBounceOn set the uBounce of pCtrl to kBounceOn @@ -4002,6 +4011,7 @@ on tagPart pCtrl, pKind, pColor, pFile set the uGravity of pCtrl to 1 set the uToughness of pCtrl to kToughnessDefault set the uFixedRot of pCtrl to false + set the layerMode of pCtrl to "dynamic" -- moving bodies composite on their own GPU layer (no smear/clip artifacts) b2kWake pCtrl -- so the first sync draws it end if end tagPart @@ -4507,8 +4517,7 @@ on recordJoint pKind, pJoint, pA, pB, pX, pY, pVis put empty into gJEnd[gJN] put "cb_joint_" & gJN into tG create graphic tG - set the layerMode of graphic tG to "dynamic" -- re-pointed every frame; also shows at once in idle Build mode - put true into gNewControls + set the layerMode of graphic tG to "dynamic" -- re-pointed every running frame if pVis is "none" then set the visible of graphic tG to false -- internal structural pin: no marker else if pVis is "dot" then @@ -6347,11 +6356,10 @@ on drawWires -- time both churns the compositor and would re-trigger its nudge. if there is no graphic tName then create graphic tName - set the layerMode of graphic tName to "dynamic" -- shows at once in idle Build mode + set the layerMode of graphic tName to "dynamic" -- endpoints move with their parts set the style of graphic tName to "line" set the lineSize of graphic tName to 2 set the foregroundColor of graphic tName to "232,200,72" - put true into gNewControls end if set the points of graphic tName to round(tx0) & "," & round(ty0) & cr & round(tx1) & "," & round(ty1) end repeat @@ -6629,8 +6637,6 @@ on refreshImagePanel set the uImgMenu of button ("ui_act_imgremove_" & tI) to true end repeat setImageVisible true - put true into gNewControls - nudgeCompositor -- the rows are created while the panel opens; they must show now end refreshImagePanel -- Place an instance of a library image at the top of the stage, then select it. @@ -6653,6 +6659,8 @@ end placeAssetFromPanel on setImageVisible pFlag local tI, tCtrl, tList + if pFlag then enterModalView + else exitModalView put empty into tList repeat with tI = 1 to the number of controls of this card put the long id of control tI of this card into tCtrl @@ -9248,6 +9256,8 @@ end hideRecipesMenu -- them: setting the layer mid-loop reorders controls and would skip some. on setRecipeVisible pFlag local tI, tCtrl, tList + if pFlag then enterModalView + else exitModalView put empty into tList repeat with tI = 1 to the number of controls of this card put the long id of control tI of this card into tCtrl @@ -9367,6 +9377,8 @@ end handleFilterToggle on setFilterVisible pFlag local tI, tCtrl, tList + if pFlag then enterModalView + else exitModalView put empty into tList repeat with tI = 1 to the number of controls of this card put the long id of control tI of this card into tCtrl @@ -9458,6 +9470,8 @@ end handleWorldToggle on setWorldVisible pFlag local tI, tCtrl, tList + if pFlag then enterModalView + else exitModalView put empty into tList repeat with tI = 1 to the number of controls of this card put the long id of control tI of this card into tCtrl @@ -9818,8 +9832,6 @@ on startOnboarding put 1 into gOnbStep buildOnboardingOverlay showOnboardingStep - put true into gNewControls - nudgeCompositor -- the tour can be replayed mid-session; the fresh overlay must show now end startOnboarding on buildOnboardingOverlay