feat: embed surface — export in-canvas affordances + transparent/faint-ink viewer support

Lets a host mount the editor's real editing experience on a bare <Viewer>
without the full <Editor> 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 / <Viewer transparent>: 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) <noreply@anthropic.com>
This commit is contained in:
Aymeric Rabot
2026-06-17 11:07:43 -04:00
committed by Wassim SAMAD
co-authored by Claude Opus 4.8
parent 8775734aa8
commit 3cb37c98cd
4 changed files with 83 additions and 6 deletions
+24
View File
@@ -12,12 +12,34 @@ export { default as Editor } from './components/editor'
// surface uses the shorter, shell-friendly names from the unified // surface uses the shorter, shell-friendly names from the unified
// preset-system spec. // preset-system spec.
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu' 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 `<Viewer>` without the full `<Editor>` 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 { export {
DimensionPill, DimensionPill,
type DimensionPillPart, type DimensionPillPart,
formatMeasurement, formatMeasurement,
MeasurementPill, MeasurementPill,
} from './components/editor/measurement-pill' } from './components/editor/measurement-pill'
export { NodeArrowHandles } from './components/editor/node-arrow-handles'
export { export {
type SnapshotCameraData, type SnapshotCameraData,
ThumbnailGenerator, ThumbnailGenerator,
@@ -40,6 +62,7 @@ export {
type FencePlanPoint, type FencePlanPoint,
snapFenceDraftPoint, snapFenceDraftPoint,
} from './components/tools/fence/fence-drafting' } from './components/tools/fence/fence-drafting'
export { MoveTool } from './components/tools/item/move-tool'
// Placement-math helpers — shared by kind-owned placement tools in // Placement-math helpers — shared by kind-owned placement tools in
// `@pascal-app/nodes` (wall curve sagitta snap, door / window placement, // `@pascal-app/nodes` (wall curve sagitta snap, door / window placement,
// item drop) so kinds don't reach into editor internals. // item drop) so kinds don't reach into editor internals.
@@ -101,6 +124,7 @@ export {
DEFAULT_STAIR_TYPE, DEFAULT_STAIR_TYPE,
DEFAULT_STAIR_WIDTH, DEFAULT_STAIR_WIDTH,
} from './components/tools/stair/stair-defaults' } from './components/tools/stair/stair-defaults'
export { ToolManager } from './components/tools/tool-manager'
export { export {
createWallOnCurrentLevel, createWallOnCurrentLevel,
getSegmentGridStep, getSegmentGridStep,
@@ -8,7 +8,7 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber' 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 * as THREE from 'three/webgpu'
import { hasDrawableGeometry } from '../../lib/drawable-geometry' import { hasDrawableGeometry } from '../../lib/drawable-geometry'
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf' import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
@@ -269,6 +269,7 @@ interface ViewerProps {
perf?: boolean perf?: boolean
useBvh?: boolean useBvh?: boolean
renderContext?: RenderContext renderContext?: RenderContext
transparent?: boolean
defaultRender?: { defaultRender?: {
shading?: RenderShading shading?: RenderShading
textures?: boolean textures?: boolean
@@ -312,6 +313,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
perf = false, perf = false,
useBvh = true, useBvh = true,
renderContext = 'editor', renderContext = 'editor',
transparent,
defaultRender, defaultRender,
isolate, isolate,
sceneReadyKey, sceneReadyKey,
@@ -342,6 +344,16 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
}, [isolate]) }, [isolate])
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark') 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 defaultShading = defaultRender?.shading
const defaultTextures = defaultRender?.textures const defaultTextures = defaultRender?.textures
const defaultColorPreset = defaultRender?.colorPreset const defaultColorPreset = defaultRender?.colorPreset
@@ -386,7 +398,9 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
return ( return (
<Canvas <Canvas
camera={{ position: [50, 50, 50], fov: 50 }} camera={{ position: [50, 50, 50], fov: 50 }}
className={`transition-colors duration-700 ${isDark ? 'bg-[#1f2433]' : 'bg-[#fafafa]'}`} className={`transition-colors duration-700 ${
transparentBackground ? 'bg-transparent' : isDark ? 'bg-[#1f2433]' : 'bg-[#fafafa]'
}`}
dpr={[1, maxDpr]} dpr={[1, maxDpr]}
frameloop="never" frameloop="never"
gl={ gl={
@@ -396,7 +410,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
if (cached) return cached if (cached) return cached
const promise = (async () => { const promise = (async () => {
try { try {
const renderer = new THREE.WebGPURenderer(props as any) const renderer = new THREE.WebGPURenderer({ ...(props as any), alpha: true })
renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMapping = THREE.ACESFilmicToneMapping
renderer.toneMappingExposure = getSceneTheme( renderer.toneMappingExposure = getSceneTheme(
useViewer.getState().sceneTheme, useViewer.getState().sceneTheme,
@@ -15,6 +15,8 @@ import {
oscSine, oscSine,
output, output,
pass, pass,
premultiplyAlpha,
renderOutput,
sample, sample,
time, time,
uniform, uniform,
@@ -180,6 +182,8 @@ const PostProcessingPasses = ({
const projectId = useViewer((s) => s.projectId) const projectId = useViewer((s) => s.projectId)
const shading = useViewer((s) => s.shading) const shading = useViewer((s) => s.shading)
const edges = useViewer((s) => s.edges) const edges = useViewer((s) => s.edges)
const inkOpacityOverride = useViewer((s) => s.inkOpacity)
const transparentBackground = useViewer((s) => s.transparentBackground)
const lastProjectIdRef = useRef(projectId) const lastProjectIdRef = useRef(projectId)
// Bump this to force a pipeline rebuild (used by retry logic) // 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); // 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%. // strong reads heavier purely by being fully solid vs soft's lighter 50%.
const inkRadius = 1 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', { console.log('[viewer/post-processing] Building pipeline', {
version: pipelineVersion, version: pipelineVersion,
@@ -283,6 +287,7 @@ const PostProcessingPasses = ({
hoverHighlightMode, hoverHighlightMode,
projectId, projectId,
shading, shading,
transparentBackground,
rendererCtor: (renderer as any).constructor?.name, rendererCtor: (renderer as any).constructor?.name,
width, width,
height, height,
@@ -420,6 +425,7 @@ const PostProcessingPasses = ({
// Single merged outline node: one shared depth pass for both selected + hovered groups. // Single merged outline node: one shared depth pass for both selected + hovered groups.
const outliner = useViewer.getState().outliner const outliner = useViewer.getState().outliner
let compositeWithOutlines = sceneColor let compositeWithOutlines = sceneColor
let visualAlpha = contentAlpha
if (outlineEnabled) { if (outlineEnabled) {
const outlineNode = mergedOutline(scene, camera, { const outlineNode = mergedOutline(scene, camera, {
primaryObjects: outliner.selectedObjects, primaryObjects: outliner.selectedObjects,
@@ -447,6 +453,11 @@ const PostProcessingPasses = ({
.mul(hoverStrength) .mul(hoverStrength)
.mul(osc) .mul(osc)
const outlineAlpha = outlineNode.primaryVisibleEdge
.max(outlineNode.primaryHiddenEdge)
.max(outlineNode.secondaryVisibleEdge)
.max(outlineNode.secondaryHiddenEdge)
visualAlpha = visualAlpha.max(outlineAlpha)
compositeWithOutlines = vec4( compositeWithOutlines = vec4(
add(sceneColor.rgb, selectedOutline.add(hoverOutline)), add(sceneColor.rgb, selectedOutline.add(hoverOutline)),
sceneColor.a, sceneColor.a,
@@ -457,9 +468,22 @@ const PostProcessingPasses = ({
// Editor overlays painted on top by their own alpha — they never get inked, // 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. // AO'd, or outlined, and always read crisp regardless of scene depth.
const withOverlay = mix(composited, overlayColor.rgb, overlayColor.a) const withOverlay = mix(composited, overlayColor.rgb, overlayColor.a)
const finalOutput = vec4(withOverlay, float(1)) let finalOutput: ReturnType<typeof premultiplyAlpha> | ReturnType<typeof vec4> = 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) const renderPipeline = new RenderPipeline(renderer as unknown as WebGPURenderer)
renderPipeline.outputColorTransform = !transparentBackground
renderPipeline.outputNode = finalOutput renderPipeline.outputNode = finalOutput
renderPipelineRef.current = renderPipeline renderPipelineRef.current = renderPipeline
retryCountRef.current = 0 retryCountRef.current = 0
@@ -494,11 +518,13 @@ const PostProcessingPasses = ({
hoverStrength, hoverStrength,
hoverVisibleColor, hoverVisibleColor,
edges, edges,
inkOpacityOverride,
pipelineVersion, pipelineVersion,
projectId, projectId,
renderer, renderer,
scene, scene,
shading, shading,
transparentBackground,
size.height, size.height,
size.width, size.width,
zoneLayers, zoneLayers,
@@ -525,7 +551,7 @@ const PostProcessingPasses = ({
if (PERF_POST_FX_DISABLED || hasPipelineErrorRef.current || !renderPipelineRef.current) { if (PERF_POST_FX_DISABLED || hasPipelineErrorRef.current || !renderPipelineRef.current) {
try { try {
if ((renderer as any).setClearAlpha) { 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 const submittedAt = PERF_OVERLAY_ENABLED ? performance.now() : 0
;(renderer as any).render(scene, camera) ;(renderer as any).render(scene, camera)
+13
View File
@@ -75,6 +75,13 @@ type ViewerState = {
showGrid: boolean showGrid: boolean
setShowGrid: (show: boolean) => void 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 projectId: string | null
setProjectId: (id: string | null) => void setProjectId: (id: string | null) => void
projectPreferences: Record< projectPreferences: Record<
@@ -283,6 +290,12 @@ const useViewer = create<ViewerState>()(
return { showGrid: show, projectPreferences } return { showGrid: show, projectPreferences }
}), }),
transparentBackground: false,
setTransparentBackground: (transparent) => set({ transparentBackground: transparent }),
inkOpacity: null,
setInkOpacity: (opacity) => set({ inkOpacity: opacity }),
projectId: null, projectId: null,
setProjectId: (id) => setProjectId: (id) =>
set((state) => { set((state) => {