From 3cb37c98cd732aa4ae8561836820ee435ef15511 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 16 Jun 2026 14:41:19 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20embed=20surface=20=E2=80=94=20export=20?= =?UTF-8?q?in-canvas=20affordances=20+=20transparent/faint-ink=20viewer=20?= =?UTF-8?q?support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a host mount the editor's real editing experience on a bare without the full shell. editor: export Grid, NodeArrowHandles, MoveTool, ToolManager — the selection handles, the kind-owned mover, the build-tool host, and the drafting grid that feeds tools their grid:* pointer events. See the doc comment in index.tsx for how they cooperate with host camera controls and selection. viewer: two opt-in, non-persisted presentation flags (both default off, so the editor and every other consumer are unchanged): - transparentBackground / : emit premultiplied RGBA masked by geometry + outline alpha (outputColorTransform off on that path) so the scene can float on any page background. ACES tone-mapping makes a true-white opaque background impossible, hence transparency. - inkOpacity: override the per-mode ink-edge opacity. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/editor/src/index.tsx | 24 ++++++++++++++ .../viewer/src/components/viewer/index.tsx | 20 ++++++++++-- .../src/components/viewer/post-processing.tsx | 32 +++++++++++++++++-- packages/viewer/src/store/use-viewer.ts | 13 ++++++++ 4 files changed, 83 insertions(+), 6 deletions(-) diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index b9833369a..120915ed5 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -12,12 +12,34 @@ export { default as Editor } from './components/editor' // surface uses the shorter, shell-friendly names from the unified // preset-system spec. export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' +// Embed surface — the editor's real in-canvas affordances, so a host can mount +// authentic selection handles, interactive build tools, and the mover on top +// of a bare `` without the full `` shell. +// - `NodeArrowHandles` renders the selected node's registry resize/rotate/move +// handles. +// - `MoveTool` runs the kind-owned mover once a translate handle arms +// `useEditor.movingNode`. +// - `ToolManager` mounts the active registry build tool (wall / door / window / +// …) for interactive placement when `useEditor` is in build mode with a +// tool, plus the snap/alignment guide layers. Mount it only while a tool is +// active to avoid its select-mode boundary editors. +// - `Grid` is the interactive drafting plane: it raycasts the pointer and +// emits the `grid:move` / `grid:click` events the build tools consume (the +// wall tool is driven entirely by them; door/window use them for free-follow +// alongside the viewer's `wall:*` mesh events). Without it the tools mount +// but their cursor never tracks the pointer. Mount it while a tool is active. +// All read `useViewer` selection + `useEditor` state, and cooperate with host +// camera controls via the `useViewer.inputDragging` / `useEditor.movingNode` +// flags. Tools place onto `useViewer.selection.levelId`, so the host must set a +// building + level selection first. +export { Grid } from './components/editor/grid' export { DimensionPill, type DimensionPillPart, formatMeasurement, MeasurementPill, } from './components/editor/measurement-pill' +export { NodeArrowHandles } from './components/editor/node-arrow-handles' export { type SnapshotCameraData, ThumbnailGenerator, @@ -40,6 +62,7 @@ export { type FencePlanPoint, snapFenceDraftPoint, } from './components/tools/fence/fence-drafting' +export { MoveTool } from './components/tools/item/move-tool' // Placement-math helpers — shared by kind-owned placement tools in // `@pascal-app/nodes` (wall curve sagitta snap, door / window placement, // item drop) so kinds don't reach into editor internals. @@ -101,6 +124,7 @@ export { DEFAULT_STAIR_TYPE, DEFAULT_STAIR_WIDTH, } from './components/tools/stair/stair-defaults' +export { ToolManager } from './components/tools/tool-manager' export { createWallOnCurrentLevel, getSegmentGridStep, diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 6f07db6d4..f4450ab96 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -8,7 +8,7 @@ import { useScene, } from '@pascal-app/core' import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' -import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react' +import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react' import * as THREE from 'three/webgpu' import { hasDrawableGeometry } from '../../lib/drawable-geometry' import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' @@ -269,6 +269,7 @@ interface ViewerProps { perf?: boolean useBvh?: boolean renderContext?: RenderContext + transparent?: boolean defaultRender?: { shading?: RenderShading textures?: boolean @@ -312,6 +313,7 @@ const Viewer = forwardRef(function Viewer( perf = false, useBvh = true, renderContext = 'editor', + transparent, defaultRender, isolate, sceneReadyKey, @@ -342,6 +344,16 @@ const Viewer = forwardRef(function Viewer( }, [isolate]) const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') + const transparentBackground = useViewer((state) => state.transparentBackground) + useLayoutEffect(() => { + if (transparent === undefined) return + + useViewer.getState().setTransparentBackground(transparent) + return () => { + useViewer.getState().setTransparentBackground(false) + } + }, [transparent]) + const defaultShading = defaultRender?.shading const defaultTextures = defaultRender?.textures const defaultColorPreset = defaultRender?.colorPreset @@ -386,7 +398,9 @@ const Viewer = forwardRef(function Viewer( return ( (function Viewer( if (cached) return cached const promise = (async () => { try { - const renderer = new THREE.WebGPURenderer(props as any) + const renderer = new THREE.WebGPURenderer({ ...(props as any), alpha: true }) renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMappingExposure = getSceneTheme( useViewer.getState().sceneTheme, diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 67645cf5b..767832ed0 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -15,6 +15,8 @@ import { oscSine, output, pass, + premultiplyAlpha, + renderOutput, sample, time, uniform, @@ -180,6 +182,8 @@ const PostProcessingPasses = ({ const projectId = useViewer((s) => s.projectId) const shading = useViewer((s) => s.shading) const edges = useViewer((s) => s.edges) + const inkOpacityOverride = useViewer((s) => s.inkOpacity) + const transparentBackground = useViewer((s) => s.transparentBackground) const lastProjectIdRef = useRef(projectId) // Bump this to force a pipeline rebuild (used by retry logic) @@ -272,7 +276,7 @@ const PostProcessingPasses = ({ // Same 1px line thickness for both (soft's thickness is the nice one); // strong reads heavier purely by being fully solid vs soft's lighter 50%. const inkRadius = 1 - const inkOpacity = edges === 'strong' ? 1 : 0.5 + const inkOpacity = inkOpacityOverride ?? (edges === 'strong' ? 1 : 0.5) console.log('[viewer/post-processing] Building pipeline', { version: pipelineVersion, @@ -283,6 +287,7 @@ const PostProcessingPasses = ({ hoverHighlightMode, projectId, shading, + transparentBackground, rendererCtor: (renderer as any).constructor?.name, width, height, @@ -420,6 +425,7 @@ const PostProcessingPasses = ({ // Single merged outline node: one shared depth pass for both selected + hovered groups. const outliner = useViewer.getState().outliner let compositeWithOutlines = sceneColor + let visualAlpha = contentAlpha if (outlineEnabled) { const outlineNode = mergedOutline(scene, camera, { primaryObjects: outliner.selectedObjects, @@ -447,6 +453,11 @@ const PostProcessingPasses = ({ .mul(hoverStrength) .mul(osc) + const outlineAlpha = outlineNode.primaryVisibleEdge + .max(outlineNode.primaryHiddenEdge) + .max(outlineNode.secondaryVisibleEdge) + .max(outlineNode.secondaryHiddenEdge) + visualAlpha = visualAlpha.max(outlineAlpha) compositeWithOutlines = vec4( add(sceneColor.rgb, selectedOutline.add(hoverOutline)), sceneColor.a, @@ -457,9 +468,22 @@ const PostProcessingPasses = ({ // Editor overlays painted on top by their own alpha — they never get inked, // AO'd, or outlined, and always read crisp regardless of scene depth. const withOverlay = mix(composited, overlayColor.rgb, overlayColor.a) - const finalOutput = vec4(withOverlay, float(1)) + let finalOutput: ReturnType | ReturnType = vec4( + withOverlay, + float(1), + ) + if (transparentBackground) { + const overlayAlpha = overlayColor.a + const alpha = overlayAlpha.add(visualAlpha.mul(overlayAlpha.oneMinus())) + const straightRgb = overlayColor.rgb + .mul(overlayAlpha) + .add(compositeWithOutlines.rgb.mul(visualAlpha).mul(overlayAlpha.oneMinus())) + .div(alpha.max(float(0.00001))) + finalOutput = premultiplyAlpha(renderOutput(vec4(straightRgb, alpha))) + } const renderPipeline = new RenderPipeline(renderer as unknown as WebGPURenderer) + renderPipeline.outputColorTransform = !transparentBackground renderPipeline.outputNode = finalOutput renderPipelineRef.current = renderPipeline retryCountRef.current = 0 @@ -494,11 +518,13 @@ const PostProcessingPasses = ({ hoverStrength, hoverVisibleColor, edges, + inkOpacityOverride, pipelineVersion, projectId, renderer, scene, shading, + transparentBackground, size.height, size.width, zoneLayers, @@ -525,7 +551,7 @@ const PostProcessingPasses = ({ if (PERF_POST_FX_DISABLED || hasPipelineErrorRef.current || !renderPipelineRef.current) { try { if ((renderer as any).setClearAlpha) { - ;(renderer as any).setClearAlpha(1) + ;(renderer as any).setClearAlpha(transparentBackground ? 0 : 1) } const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0 ;(renderer as any).render(scene, camera) diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 11cc67e3d..a4bf08c97 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -75,6 +75,13 @@ type ViewerState = { showGrid: boolean setShowGrid: (show: boolean) => void + transparentBackground: boolean + setTransparentBackground: (transparent: boolean) => void + + // Embed-controlled ink-edge opacity override (null = use the per-mode default). + inkOpacity: number | null + setInkOpacity: (opacity: number | null) => void + projectId: string | null setProjectId: (id: string | null) => void projectPreferences: Record< @@ -283,6 +290,12 @@ const useViewer = create()( return { showGrid: show, projectPreferences } }), + transparentBackground: false, + setTransparentBackground: (transparent) => set({ transparentBackground: transparent }), + + inkOpacity: null, + setInkOpacity: (opacity) => set({ inkOpacity: opacity }), + projectId: null, setProjectId: (id) => set((state) => {